diff --git a/.github/ISSUE_TEMPLATE/2-bug_report.yml b/.github/ISSUE_TEMPLATE/2-bug_report.yml index d639ea2d0..9418fa4c7 100644 --- a/.github/ISSUE_TEMPLATE/2-bug_report.yml +++ b/.github/ISSUE_TEMPLATE/2-bug_report.yml @@ -69,4 +69,4 @@ body:   If your problem is with an **Allsky Website**, also attach: \* `~/allsky/html/allsky/configuration.json` (local Website) - \* `~/allsky/config/remote_configuration.json` (remote Website) + \* `~/allsky/config/remote_configuration.json` (remote Website) \ No newline at end of file diff --git a/.github/workflows/ci_shellcheck.yml b/.github/workflows/ci_shellcheck.yml index 7f6cdf115..8da64c54a 100644 --- a/.github/workflows/ci_shellcheck.yml +++ b/.github/workflows/ci_shellcheck.yml @@ -8,6 +8,7 @@ on: paths: - '*.sh' - '*/*.sh' + - '*/*/*.sh' # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: diff --git a/.gitignore b/.gitignore index 563506f66..f2b98a91b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ images/* darks/* -scripts/endOfNight_additionalSteps.sh # development artifacts src/*.a diff --git a/README.md b/README.md index 004841586..1da11f750 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,52 @@ -# Allsky Camera ![Release](https://img.shields.io/badge/Version-v2023.05.01_05-green.svg) [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=MEBU2KN75G2NG&source=url) +# Allsky Camera ![Release](https://img.shields.io/badge/Version-v2024.12.06-green.svg) [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=MEBU2KN75G2NG&source=url) This is the source code for the Allsky Camera project described [on Instructables](http://www.instructables.com/id/Wireless-All-Sky-Camera/).  

- +

-> **This README and the [Allsky documentation](https://htmlpreview.github.io/?https://raw.githubusercontent.com/thomasjacquin/allsky/master/html/documentation/index.html) will help get your allsky camera up and running.** - -  ## Requirements -You will need the following: +In order to run the Allsky software you need: - * A Raspberry Pi (Zero 2, 2, 3, 4, or 5) running Pi OS. - * A camera (Raspberry Pi HQ, Module 3, or RPi compatible, or ZWO ASI released before October 2023) + * A Raspberry Pi Zero 2, Pi 2, Pi 3, Pi 4, Pi 5, or Le Potato. + * A camera: + * Any ZWO camera sold before October 2024. + * One of the following Raspberry Pi cameras: + * RPi HQ (IMX477 sensor) + * RPi Module 3 (IMX708 sensor) + * RPi Version 1 (OV5647 sensor; NOT RECOMMENDED: 0.9 second maximum exposure) + * IMX290 60.00 fps + * ArduCam 16 MP (IMX519 sensor) + * ArduCam 64 MP (arducam_64mp sensor) + * ArduCam 462 (arducam-pivariety sensor) + * Waveshare imx219-d160 (IMX290 sensor) + * ArduCam 64 MP Owlsight (OV64a40 sensor) + * OneInchEye IMX283 (IMX283 sensor)   -> **NOTES:** -> - Only the Raspberry Pi OS is supported (Buster, Bullseye, or Bookworm). Other operating systems like Ubuntu are NOT supported. -> - **NOTE**: support for Buster is going away so please upgrade to Bookworm. -> - The ZWO ASI120-series cameras are not recommended due to somewhat poor quality and tendency to produce timeout errors. See [Troubleshooting --> ZWO Cameras](https://htmlpreview.github.io/?https://raw.githubusercontent.com/thomasjacquin/allsky/master/html/documentation/troubleshooting/ZWOCameras.html) for notes on the ASI120-series and related T7 / T7C cameras. -> - The Pi Zero with its limited memory and _very_ limited CPU power (single CPU core), is **not** recommended. You will most likely not be able to create keograms, startrails, or timelapse videos. -> - The Pi Zero 2 with its limited memory and somewhat limited CPU power, is not recommended unless cost is the only concern. Creating keograms, startrails, and timelapse videos may or may not be possible. +> __NOTES:__ +> - Only the Raspberry Pi OS is supported. Other operating systems like Ubuntu are NOT supported. If possible use the newest Bookworm 64-bit release. Bullseye will also work. __Buster support will be dropped in the next major release__. +> - The ZWO ASI120-series cameras are __not__ recommended due to their tendency to produce errors and poor-quality images. +> - The Pi Zero with its limited memory and _very_ limited CPU power is not recommended. You probably won't be able to create keograms, startrails, or timelapse videos. +> - The Pi Zero 2 with its limited memory and somewhat limited CPU power is not recommended unless cost is the only concern. Creating keograms, startrails, and timelapse videos may or may not be possible. > - The Le Potato is the only "Pi-compatible" board that we've found to actually be compatible, so buyer beware. ----   ## Software Installation - -Detailed installation instructions can be found at [Installing / Upgrading --> Allsky](https://htmlpreview.github.io/?https://raw.githubusercontent.com/thomasjacquin/allsky/master/html/documentation/installations/Allsky.html). + +See the [detailed installation instructions](https://htmlpreview.github.io/?https://raw.githubusercontent.com/AllskyTeam/allsky/master/html/documentation/installations/Allsky.html). --- @@ -47,12 +56,12 @@ Detailed installation instructions can be found at [Installing / Upgrading --> A ## Web User Interface (WebUI)

- +

-The WebUI is now installed as part of Allsky and is used to administer Allsky, and to a lesser extent, your Pi. It can also be used to view the current image as well as all saved images, keograms, startrails, and timelapse videos. +The WebUI is used to administer Allsky, and to a lesser extent, your Pi. It can also be used to view the current image as well as all saved images, keograms, startrails, and timelapse videos. -A public page is also available in order to view the current image without having to log into the WebUI and without being able to do any administrative tasks. This can be useful for people who don't have a Allsky Website but still want to share a view of their sky: +A public page is also available in order to view the current image without having to log into the WebUI and without being able to do any administrative tasks. This can be useful for people who don't use an Allsky Website but still want to share a view of their sky: ``` http://your_raspberry_IP/public.php @@ -61,15 +70,21 @@ http://your_raspberry_IP/public.php Make sure this page is publically viewable. If it is behind a firewall consult the documentation for your network equipment for information on allowing inbound connections. +The WebUI has a link to the Allsky Documentation which describes all the settings Allsky uses as well as troubleshooting information. +It should be used before requesting support on GitHub. + ---   -## Allsky Website +## Allsky Website and remote server + +The local Allsky Website (i.e., on the Pi) is installed with Allsky but must be enabled in the WebUI in order to use it. +You can also install the Allsky Website on a remote server so it can be viewable via the Internet. -By installling the optional Allsky Website you can display your files on a website on the Pi, on another machine, or on both. +See [Installation / Upgrading --> Website](https://htmlpreview.github.io/?https://raw.githubusercontent.com/AllskyTeam/allsky/master/html/documentation/installations/AllskyWebsite.html) for information on how to install and configure an Allsky Website. -See [Installation / Upgrading --> Website](https://htmlpreview.github.io/?https://raw.githubusercontent.com/thomasjacquin/allsky/master/html/documentation/installations/AllskyWebsite.html) for information on how to install and configure an Allsky Website. +Allsky images, keograms, startrails, and timelapse videos can optionally be uploaded to a remote server __not__ running an Allsky Website. This is useful if you have a personal website and want to include the most recent Allsky images. --- @@ -84,7 +99,7 @@ Allsky supports running "modules" after each picture is taken to change the imag The Overlay Editor lets you easily specify what text and images you want in your overlay, and place them using drag-and-drop. Each field can be formatted however you want (font, color, size, position, rotation, etc.). The only limit is your imagination!! -See [Explanations / How To -> Overlays](https://htmlpreview.github.io/?https://raw.githubusercontent.com/thomasjacquin/allsky/master/html/documentation/overlays/overlays.html) and [Explanations / How To -> Modules](https://htmlpreview.github.io/?https://raw.githubusercontent.com/thomasjacquin/allsky/master/html/documentation/modules/modules.html) for more information. +See [Explanations / How To -> Overlays](https://htmlpreview.github.io/?https://raw.githubusercontent.com/AllskyTeam/allsky/master/html/documentation/overlays/overlays.html) and [Explanations / How To -> Modules](https://htmlpreview.github.io/?https://raw.githubusercontent.com/AllskyTeam/allsky/master/html/documentation/modules/modules.html) for more information. --- @@ -93,9 +108,9 @@ See [Explanations / How To -> Overlays](https://htmlpreview.github.io/?https://r ## Dark frame subtraction -Dark frame subtraction removes hot pixels from images by taking images at different temperatures with a cover on your camera lens and subtracting those images from nighttime images. +Dark frame subtraction removes white (i.e., "hot") pixels from images by taking images with a cover over the camera lens and subtracting those images from images. -See [Explanations / How To -> Dark frames](https://htmlpreview.github.io/?https://raw.githubusercontent.com/thomasjacquin/allsky/master/html/documentation/explanations/darkFrames.html) for more information. +See [Explanations / How To -> Dark frames](https://htmlpreview.github.io/?https://raw.githubusercontent.com/AllskyTeam/allsky/master/html/documentation/explanations/darkFrames.html) for more information. --- @@ -116,13 +131,13 @@ By default, a timelapse video is generated at the end of nighttime from all of t ## Keograms

- +

-A **Keogram** is an image giving a quick view of the day's activity. +A __Keogram__ is an image giving a quick view of the day's activity. For each image a central vertical column 1 pixel wide is extracted. All these columns are then stitched together from left to right. This results in a timeline that reads from dawn to the end of nighttime (the image above only shows nighttime data since daytime images were turned off). -See [Explanations / How To --> Keograms](https://htmlpreview.github.io/?https://raw.githubusercontent.com/thomasjacquin/allsky/master/html/documentation/explanations/keograms.html). +See [Explanations / How To --> Keograms](https://htmlpreview.github.io/?https://raw.githubusercontent.com/AllskyTeam/allsky/master/html/documentation/explanations/keograms.html). --- @@ -133,13 +148,13 @@ See [Explanations / How To --> Keograms](https://htmlpreview.github.io/?https:// ## Startrails

- +

-**Startrails** are generated by stacking all the images from a night on top of each other. +__Startrails__ are generated by stacking all the images from a night on top of each other. In the image above, Polaris is centered about one-fourth the way from the top. -See [Explanations / How To --> Startrails](https://htmlpreview.github.io/?https://raw.githubusercontent.com/thomasjacquin/allsky/master/html/documentation/explanations/startrails.html). +See [Explanations / How To --> Startrails](https://htmlpreview.github.io/?https://raw.githubusercontent.com/AllskyTeam/allsky/master/html/documentation/explanations/startrails.html). --- @@ -149,10 +164,10 @@ See [Explanations / How To --> Startrails](https://htmlpreview.github.io/?https: ## Automatic deletion of old data -You can specify how many days worth of images to keep in order to keep the Raspberry Pi SD card from filling up. If you have the Allsky Website installed on your Pi, you can specify how many days worth of its imags to keep. +You can specify how many days worth of images to keep in order to keep the Raspberry Pi SD card from filling up. If you are using the Allsky Website on your Pi, you can specify how many days worth of its imags to keep. -See the **DAYS_TO_KEEP** and **WEB_DAYS_TO_KEEP** settings in [Settings --> Allsky](https://htmlpreview.github.io/?https://raw.githubusercontent.com/thomasjacquin/allsky/master/html/documentation/settings/allsky.html). +See the __Days to Keep on Pi Website__ and __Web Days To Keep on Remote Website__ settings in [Settings --> Allsky](https://htmlpreview.github.io/?https://raw.githubusercontent.com/AllskyTeam/allsky/master/html/documentation/settings/allsky.html). --- @@ -163,13 +178,13 @@ See the **DAYS_TO_KEEP** and **WEB_DAYS_TO_KEEP** settings in [Settings --> Alls ## Share your sky -If you want your allsky camera added to the [Allsky map](http://www.thomasjacquin.com/allsky-map), see [Put your camera on Allsky Map](https://htmlpreview.github.io/?https://raw.githubusercontent.com/thomasjacquin/allsky/master/html/documentation/miscellaneous/AllskyMap.html). +If you want your allsky camera added to the [Allsky map](http://www.thomasjacquin.com/allsky-map), see [Put your camera on Allsky Map](https://htmlpreview.github.io/?https://raw.githubusercontent.com/AllskyTeam/allsky/master/html/documentation/miscellaneous/AllskyMap.html). If you know anyone in Greenland or Antartica, send them a camera!!

- +

@@ -181,7 +196,7 @@ If you know anyone in Greenland or Antartica, send them a camera!! ## Release changes See the -[Allsky Version Change Log](https://htmlpreview.github.io/?https://raw.githubusercontent.com/thomasjacquin/allsky/master/html/documentation/changeLog.html) +[Allsky Version Change Log](https://htmlpreview.github.io/?https://raw.githubusercontent.com/AllskyTeam/allsky/master/html/documentation/changeLog.html) for a list of changes in this release and all prior releases. --- diff --git a/allsky.sh b/allsky.sh index dc865dfde..0e5834935 100755 --- a/allsky.sh +++ b/allsky.sh @@ -1,131 +1,165 @@ #!/bin/bash -# This EXIT code is also defined in variables.sh, but in case we can't open that file, we need it here. -EXIT_ERROR_STOP=100 # unrecoverable error - need user action so stop service - # Make it easy to find the beginning of this run in the log file. echo " ***** Starting AllSky *****" -[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$(realpath "$(dirname "${BASH_ARGV0}")")" -ME="$(basename "${BASH_ARGV0}")" +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )" )" +ME="$( basename "${BASH_ARGV0}" )" -cd "${ALLSKY_HOME}" || exit 1 +# NOT_STARTED_MSG, STOPPED_MSG, ERROR_MSG_PREFIX, and ZWO_VENDOR are globals -NOT_STARTED_MSG="Unable to start Allsky!" -STOPPED_MSG="Allsky Stopped!" -ERROR_MSG_PREFIX="*** ERROR ***\n${STOPPED_MSG}\n" -#shellcheck disable=SC2086 source-path=. -source "${ALLSKY_HOME}/variables.sh" || exit ${ALLSKY_ERROR_STOP} -if [[ -z ${ALLSKY_CONFIG} ]]; then - MSG="FATAL ERROR: 'source variables.sh' did not work properly." - echo -e "${RED}*** ${MSG}${NC}" - doExit "${EXIT_ERROR_STOP}" "Error" \ - "${ERROR_MSG_PREFIX}\n$(basename "${ALLSKY_HOME}")/variables.sh\nis corrupted." \ - "${NOT_STARTED_MSG}
${MSG}" +#shellcheck source-path=. +source "${ALLSKY_HOME}/variables.sh" || exit "${EXIT_ERROR_STOP}" +#shellcheck source-path=scripts +source "${ALLSKY_SCRIPTS}/functions.sh" || exit "${EXIT_ERROR_STOP}" +#shellcheck source-path=scripts +source "${ALLSKY_SCRIPTS}/installUpgradeFunctions.sh" || exit "${EXIT_ERROR_STOP}" + +if [[ ! -d ${ALLSKY_CONFIG} ]]; then + { + echo "*** =====" + echo "Allsky needs to be installed. Run: cd ~/allsky; ./install.sh" + echo "*** =====" + } >&2 + # Can't call addMessage.sh or copy_notification_image.sh or almost anything + # since they use ${ALLSKY_CONIG} and/or ${ALLSKY_TMP} which don't exist yet. + set_allsky_status "${ALLSKY_STATUS_NEVER_RUN}" + doExit "${EXIT_ERROR_STOP}" "no-image" "" "" fi -#shellcheck disable=SC2086 source-path=scripts -source "${ALLSKY_SCRIPTS}/functions.sh" || exit ${ALLSKY_ERROR_STOP} -#shellcheck disable=SC2086,SC1091 # file doesn't exist in GitHub -source "${ALLSKY_CONFIG}/config.sh" || exit ${ALLSKY_ERROR_STOP} -#shellcheck disable=SC2086 source-path=scripts -source "${ALLSKY_SCRIPTS}/installUpgradeFunctions.sh" || exit ${ALLSKY_ERROR_STOP} +# Make sure ${CAMERA_TYPE} is valid; if not, exit with a message. +verify_CAMERA_TYPE "${CAMERA_TYPE}" + +cd "${ALLSKY_HOME}" || exit 1 # Make sure they rebooted if they were supposed to. -NEEDS_REBOOT="false" -reboot_needed && NEEDS_REBOOT="true" +if reboot_needed ; then + NEEDS_REBOOT="true" +else + NEEDS_REBOOT="false" +fi # Make sure the settings have been configured after an installation or upgrade. -LAST_CHANGED="$( settings ".lastChanged" )" -if [[ ${LAST_CHANGED} == "" ]]; then - echo "*** ===== Allsky needs to be configured before it can be used. See the WebUI." +LAST_CHANGED="$( settings ".lastchanged" )" +if [[ -z ${LAST_CHANGED} ]]; then + set_allsky_status "${ALLSKY_STATUS_SEE_WEBUI}" + echo "*** ===== Allsky needs to be configured before it can be used. See the WebUI." >&2 if [[ ${NEEDS_REBOOT} == "true" ]]; then - echo "*** ===== The Pi also needs to be rebooted." - doExit "${EXIT_ERROR_STOP}" "Error" \ + echo "*** ===== The Pi also needs to be rebooted." >&2 + doExit "${EXIT_ERROR_STOP}" "ConfigurationNeeded" \ "Allsky needs\nconfiguration\nand the Pi needs\na reboot" \ - "Allsky needs to be configured then the Pi rebooted." + "Allsky needs to be configured and then the Pi rebooted." else - doExit "${EXIT_ERROR_STOP}" "ConfigurationNeeded" "" "" - "${ALLSKY_SCRIPTS}/addMessage.sh" "Error" "Allsky needs to be configured." + doExit "${EXIT_ERROR_STOP}" "ConfigurationNeeded" "" "Allsky needs to be configured." fi elif [[ ${NEEDS_REBOOT} == "true" ]]; then - doExit "${EXIT_ERROR_STOP}" "RebootNeeded" "" "" - "${ALLSKY_SCRIPTS}/addMessage.sh" "Error" "The Pi needs to be rebooted." + set_allsky_status "${ALLSKY_STATUS_SEE_WEBUI}" + doExit "${EXIT_ERROR_STOP}" "RebootNeeded" "" "The Pi needs to be rebooted." fi SEE_LOG_MSG="See ${ALLSKY_LOG}" ARGS_FILE="${ALLSKY_TMP}/capture_args.txt" -# If a prior copy of Allsky exists, remind the user. +# If a prior copy of Allsky exists, remind the user if we've never reminded before, +# or it's been at least a week since the last reminder. if [[ -d ${PRIOR_ALLSKY_DIR} ]]; then - MSG="Reminder: your prior Allsky is still in '${PRIOR_ALLSKY_DIR}'." - MSG="${MSG}\nIf you are no longer using it, it can be removed to save disk space:" - MSG="${MSG}\n   rm -fr '${PRIOR_ALLSKY_DIR}'\n" - "${ALLSKY_SCRIPTS}/addMessage.sh" "info" "${MSG}" + DO_MSG="true" + if [[ -f ${OLD_ALLSKY_REMINDER} ]]; then + CHECK_DATE="$( date -d '1 week ago' +'%Y%m%d%H%M.%S' )" + CHECK_FILE="${ALLSKY_TMP}/check_date" + touch -t "${CHECK_DATE}" "${CHECK_FILE}" + [[ ${OLD_ALLSKY_REMINDER} -nt "${CHECK_FILE}" ]] && DO_MSG="false" + rm -f "${CHECK_FILE}" + fi + if [[ ${DO_MSG} == "true" ]]; then + MSG="Reminder: your prior Allsky is still in '${PRIOR_ALLSKY_DIR}'." + MSG+="\nIf you are no longer using it, it can be removed to save disk space:" + MSG+="\n   rm -fr '${PRIOR_ALLSKY_DIR}'\n" + "${ALLSKY_SCRIPTS}/addMessage.sh" "info" "${MSG}" + touch "${OLD_ALLSKY_REMINDER}" # last time we displayed the message + fi +fi + +# If there's some checkAllsky.sh output, remind the user. +if [[ -f ${CHECK_ALLSKY_LOG} ]]; then + DO_MSG="true" + REMINDER="${ALLSKY_LOGS}/checkAllsky_reminder.txt" + if [[ -f ${REMINDER} ]]; then + CHECK_DATE="$( date -d '1 week ago' +'%Y%m%d%H%M.%S' )" + CHECK_FILE="${ALLSKY_TMP}/check_date-checkAllsky" + touch -t "${CHECK_DATE}" "${CHECK_FILE}" + [[ ${REMINDER} -nt "${CHECK_FILE}" ]] && DO_MSG="false" + rm -f "${CHECK_FILE}" + fi + if [[ ${DO_MSG} == "true" ]]; then + MSG="
" + MSG+="Reminder to make these changes to your settings" + MSG+="
" + MSG+="$( < "${CHECK_ALLSKY_LOG}" )" + MSG+="
If you made the changes run:" + MSG+="\n   rm -f '${CHECK_ALLSKY_LOG}'\n" + "${ALLSKY_SCRIPTS}/addMessage.sh" "warning" "${MSG}" + touch "${REMINDER}" # last time we displayed the message + fi fi # This file contains information the user needs to act upon after an installation. if [[ -f ${POST_INSTALLATION_ACTIONS} ]]; then - # If there's an initial message display an image and stop. + # If there's an initial message created during installation, display an image and stop. F="${POST_INSTALLATION_ACTIONS}_initial_message" if [[ -f ${F} ]]; then # There is already a message so don't add another, # and there's already an image, so don't overwrite it. # shellcheck disable=SC2154 - rm "${F}" # so next time we'll remind them. + rm -f "${F}" # so next time we'll remind them. + set_allsky_status "${ALLSKY_STATUS_SEE_WEBUI}" doExit "${EXIT_ERROR_STOP}" "no-image" "" "" else - MSG="Reminder to perform the action(s) in '${POST_INSTALLATION_ACTIONS}'." - MSG="${MSG}\nIf you already have, remove the file so you will no longer see this message:" - MSG="${MSG}\n    rm -f '${POST_INSTALLATION_ACTIONS}'" - "${ALLSKY_SCRIPTS}/addMessage.sh" "info" "${MSG}" + MSG="Reminder: Click here to see the action(s) that need to be performed." + MSG+="\nOnce you perform them run the following to remove this message:" + MSG+="\n    rm -f '${POST_INSTALLATION_ACTIONS}'" + PIA="${POST_INSTALLATION_ACTIONS/${ALLSKY_HOME}/}" + "${ALLSKY_SCRIPTS}/addMessage.sh" "warning" "${MSG}" "${PIA}" fi fi -USE_NOTIFICATION_IMAGES=$(settings ".notificationimages") - -if [[ -z ${CAMERA_TYPE} ]]; then - MSG="FATAL ERROR: 'Camera Type' not set in WebUI." - echo -e "${RED}*** ${MSG}${NC}" - doExit "${EXIT_NO_CAMERA}" "Error" \ - "${ERROR_MSG_PREFIX}\nCamera Type\nnot specified\nin the WebUI." \ - "${NOT_STARTED_MSG}
${MSG}" -fi +USE_NOTIFICATION_IMAGES="$( settings ".notificationimages" )" || exit "${EXIT_ERROR_STOP}" # Make sure we are not already running. pgrep "${ME}" | grep -v $$ | xargs "sudo kill -9" 2>/dev/null -if [[ ${CAMERA_TYPE} == "RPi" ]]; then - # "true" means use doExit() on error - RPi_COMMAND_TO_USE="$(determineCommandToUse "true" "${ERROR_MSG_PREFIX}" )" - -elif [[ ${CAMERA_TYPE} == "ZWO" ]]; then +# Get the list of connected cameras and make sure the one we want is connected. +if [[ ${CAMERA_TYPE} == "ZWO" ]]; then RPi_COMMAND_TO_USE="" RESETTING_USB_LOG="${ALLSKY_TMP}/resetting_USB.txt" + reset_usb() # resets the USB bus { - REASON="${1}" # why are we resetting the bus? + local REASON="${1}" # why are we resetting the bus? + local MSG IMAGE_MSG # Only reset a couple times, then exit with fatal error. if [[ -f ${RESETTING_USB_LOG} ]]; then NUM_USB_RESETS=$( < "${RESETTING_USB_LOG}" ) - if [[ ${NUM_USB_RESETS} -eq 2 ]]; then - MSG="FATAL ERROR: Too many consecutive USB bus resets done (${NUM_USB_RESETS})." - echo -e "${RED}*** ${MSG} Stopping." >&2 + if [[ ${NUM_USB_RESETS} -ge 2 ]]; then rm -f "${RESETTING_USB_LOG}" - doExit "${EXIT_ERROR_STOP}" "Error" \ - "${ERROR_MSG_PREFIX}\nToo many consecutive\nUSB bus resets done!\n${SEE_LOG_MSG}" \ - "${NOT_STARTED_MSG}
${MSG}" + + MSG="Too many consecutive USB bus resets done (${NUM_USB_RESETS})." + echo -e "${RED}*** ${FATAL_MSG} ${MSG} Stopping Allsky.${NC}" >&2 + IMAGE_MSG="${ERROR_MSG_PREFIX}" + IMAGE_MSG+="\nToo many consecutive\nUSB bus resets done!\n${SEE_LOG_MSG}" + doExit "${EXIT_ERROR_STOP}" "Error" + "${IMAGE_MSG}" "${NOT_STARTED_MSG}: ${MSG}" fi else NUM_USB_RESETS=0 fi - MSG="${YELLOW}WARNING: Resetting USB ports ${REASON/\\n/ }" - if [[ ${ON_TTY} -eq 1 ]]; then - echo "${MSG}; restart ${ME} when done.${NC}" >&2 + MSG="WARNING: Resetting USB ports ${REASON/\\n/ }" + if [[ ${ON_TTY} == "true" ]]; then + echo "${YELLOW}${MSG}; restart ${ME} when done.${NC}" >&2 else - echo "${MSG}, then restarting.${NC}" >&2 + echo "${MSG}, then restarting." >&2 # The service will automatically restart this script. fi @@ -133,64 +167,66 @@ elif [[ ${CAMERA_TYPE} == "ZWO" ]]; then echo "${NUM_USB_RESETS}" > "${RESETTING_USB_LOG}" # Display a warning message - "${ALLSKY_SCRIPTS}/generate_notification_images.sh" --directory "${ALLSKY_TMP}" "${FILENAME}" \ - "yellow" "" "85" "" "" \ - "" "5" "yellow" "${EXTENSION}" "" "WARNING:\n\nResetting USB bus\n${REASON}.\nAttempt ${NUM_USB_RESETS}." - sudo "$UHUBCTL_PATH" -a cycle -l "$UHUBCTL_PORT" + "${ALLSKY_SCRIPTS}/generate_notification_images.sh" --directory "${ALLSKY_TMP}" \ + "${FILENAME}" "yellow" "" "85" "" "" \ + "" "5" "yellow" "${EXTENSION}" "" \ + "WARNING:\n\nResetting USB bus\n${REASON}.\nAttempt ${NUM_USB_RESETS}." + + SEARCH="${ZWO_VENDOR}:${ZWO_CAMERA_ID}" + sudo "${ALLSKY_BIN}/uhubctl" --action off --exact --search "${SEARCH}" sleep 3 # give it a few seconds, plus, allow the notification images to be seen + sudo "${ALLSKY_BIN}/uhubctl" --action on --exact --search "${SEARCH}" } - # "03c3" is the USB ID for ZWO devices. - ZWOdev=$(lsusb -d '03c3:' | awk '{ bus=$2; dev=$4; gsub(/[^0-9]/,"",dev); print "/dev/bus/usb/"bus"/"dev;}') - # We have to run "lsusb -D" once for each device returned by "lsusb -d", and can't - # use "echo x | while read" because variables set inside the "while" loop don't get exposed - # to the calling code, so use a temp file. - - TEMP="${ALLSKY_TMP}/${CAMERA_TYPE}_cameras.txt" - echo "${ZWOdev}" > "${TEMP}" - NUM=0 - while read -r DEV - do - lsusb -D "${DEV}" 2>/dev/null | grep --silent 'iProduct .*ASI[0-9]' && ((NUM++)) - done < "${TEMP}" - - if [[ ${NUM} -eq 0 ]]; then - if [[ -n ${UHUBCTL_PATH} ]] ; then - reset_usb "looking for a\nZWO camera" # reset_usb exits if too many tries - exit 0 # exit with 0 so the service is restarted - else - MSG="FATAL ERROR: ZWO Camera not found" - echo -en "${RED}*** ${MSG}" >&2 - if [[ ${ZWOdev} == "" ]]; then - echo " and no USB entry either.${NC}" >&2 - USB_MSG="" - else - echo " but ${ZWOdev} found.${NC}" >&2 - USB_MSG="\n${SEE_LOG_MSG}" - fi +else # RPi + # "true" means use doExit() on error + RPi_COMMAND_TO_USE="$( determineCommandToUse "true" "${ERROR_MSG_PREFIX}" "false" )" +fi - echo " If you have the 'uhubctl' command installed, add it to config.sh." >&2 - echo " In the meantime, try running it to reset the USB bus." >&2 - doExit "${EXIT_NO_CAMERA}" "Error" \ - "${ERROR_MSG_PREFIX}\nNo ZWO camera\nfound!${USB_MSG}" \ - "${NOT_STARTED_MSG}
${MSG}
${SEE_LOG_MSG}." - fi +# "true" means ignore errors +get_connected_cameras_info "true" > "${CONNECTED_CAMERAS_INFO}" +if grep --silent "^${CAMERA_TYPE}" "${CONNECTED_CAMERAS_INFO}" ; then + CAMERA_TYPE_FOUND="true" +else + CAMERA_TYPE_FOUND="false" +fi + +if [[ ${CAMERA_TYPE_FOUND} == "false" ]]; then + if [[ ${CAMERA_TYPE} == "ZWO" ]]; then + # reset_usb() exits if too many tries + reset_usb "looking for a\nZWO camera" + set_allsky_status "${ALLSKY_STATUS_SEE_WEBUI}" + exit 0 # exit with 0 so the service is restarted fi - rm -f "${RESETTING_USB_LOG}" # We found the camera so don't need to reset. + set_allsky_status "${ALLSKY_STATUS_SEE_WEBUI}" + MSG="${NOT_STARTED_MSG} No connected ${CAMERA_TYPE} cameras found!" + IMAGE_MSG="${ERROR_MSG_PREFIX}" + IMAGE_MSG+="${NOT_STARTED_MSG}\n" + IMAGE_MSG+="\nNo connected ${CAMERA_TYPE}\ncameras found!" + doExit "${EXIT_ERROR_STOP}" "Error" \ + "${IMAGE_MSG}" "${MSG}" +fi -else - MSG="FATAL ERROR: Unknown Camera Type: ${CAMERA_TYPE}." - echo -e "${RED}${MSG} Stopping.${NC}" >&2 - doExit "${EXIT_NO_CAMERA}" "Error" \ - "${ERROR_MSG_PREFIX}\nUnknown Camera\nType: ${CAMERA_TYPE}" \ - "${NOT_STARTED_MSG}
${MSG}" +# Make sure the current camera is supported and hasn't changed unexpectedly. +CAM="${CAMERA_TYPE} ${CAMERA_NUMBER} ${CAMERA_MODEL}" # has TABS +CCM="$( get_connected_camera_models --full "${CAMERA_TYPE}" )" +read -r CC_TYPE CC_NUMBER CC_MODEL <<<"${CCM}" +if ! echo -e "${CCM}" | grep --silent "${CAM}" ; then + # Something changed. validate_camera() displays the error message. + if ! validate_camera "${CC_TYPE}" "${CC_MODEL}" "${CC_NUMBER}" ; then + set_allsky_status "${ALLSKY_STATUS_SEE_WEBUI}" + IMAGE_MSG="${ERROR_MSG_PREFIX}" + IMAGE_MSG+="The camera changed." + IMAGE_MSG+="\nCheck Camera Type\n& Model in the WebUI." + doExit "${EXIT_ERROR_STOP}" "Error" "${IMAGE_MSG}" + fi fi # Make sure the settings file is linked to the camera-specific file. if ! MSG="$( check_settings_link "${SETTINGS_FILE}" )" ; then "${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${MSG}" - echo "ERROR: Settings file (${SETTINGS_FILE}) not linked correctly." >&2 + echo "ERROR: ${MSG}" >&2 fi # Make directories that need to exist. @@ -216,118 +252,123 @@ mkdir "${ALLSKY_ABORTS_DIR}" sudo chgrp "${WEBSERVER_GROUP}" "${ALLSKY_ABORTS_DIR}" sudo chmod 775 "${ALLSKY_ABORTS_DIR}" +rm -f "${ALLSKY_NOTIFICATION_LOG}" # clear out any notificatons from prior runs. + # Optionally display a notification image. -if [[ $USE_NOTIFICATION_IMAGES -eq 1 ]]; then +if [[ ${USE_NOTIFICATION_IMAGES} == "true" ]]; then # Can do this in the background to speed up startup. - "${ALLSKY_SCRIPTS}/copy_notification_image.sh" "StartingUp" 2>&1 & + "${ALLSKY_SCRIPTS}/copy_notification_image.sh" --expires 0 "StartingUp" 2>&1 & fi : > "${ARGS_FILE}" -# If the locale isn't in the settings file, try to determine it. -LOCALE="$(settings .locale)" -if [[ -z ${LOCALE} ]]; then - if [[ -n ${LC_ALL} ]]; then - echo "-Locale=${LC_ALL}" >> "${ARGS_FILE}" - elif [[ -n ${LANG} ]]; then - echo "-lOcale=${LANG}" >> "${ARGS_FILE}" - elif [[ -n ${LANGUAGE} ]]; then - echo "-loCale=${LANGUAGE}" >> "${ARGS_FILE}" - fi +# Only pass settings that are used by the capture program. +if ! ARGS="$( "${ALLSKY_SCRIPTS}/convertJSON.php" --capture-only )" ; then + echo "${ME}: ERROR: convertJSON.php returned: ${ARGS}" + set_allsky_status "${ALLSKY_STATUS_ERROR}" + exit "${EXIT_ERROR_STOP}" fi +# We must pass "-config ${ARGS_FILE}" on the command line and +# other settings needed at the start of the capture program. +echo "${ARGS}" | + grep -E -i -v "^config=|^debuglevel=^cmd=|^cameramodel|^cameranumber|^locale=" >> "${ARGS_FILE}" -# We must pass "-config ${ARGS_FILE}" on the command line, -# and debuglevel we did above, so don't do them again. -TAB="$( echo -e "\t" )" -convert_json_to_tabs "${SETTINGS_FILE}" | - grep -E -i -v "^config${TAB}|^debuglevel${TAB}" | - sed -e 's/^/-/' -e "s/${TAB}/=/" >> "${ARGS_FILE}" +# When using a desktop environment a preview of the capture can be displayed. +# The preview mode does not work if we are started as a service or +# if the debian distribution has no desktop environment. +{ + [[ $1 == "preview" ]] && echo "preview=true" -# When using a desktop environment a preview of the capture can be displayed in a separate window. -# The preview mode does not work if we are started as a service or if the debian distribution has no desktop environment. -[[ $1 == "preview" ]] && echo "-preview=1" >> "${ARGS_FILE}" + echo "version=${ALLSKY_VERSION}" + echo "save_dir=${CAPTURE_SAVE_DIR}" -echo "-version=${ALLSKY_VERSION}" >> "${ARGS_FILE}" -echo "-save_dir=${CAPTURE_SAVE_DIR}" >> "${ARGS_FILE}" +} >> "${ARGS_FILE}" -FREQUENCY_FILE="${ALLSKY_TMP}/IMG_UPLOAD_FREQUENCY.txt" # If the user wants images uploaded only every n times, save that number to a file. if [[ ${IMG_UPLOAD_FREQUENCY} -ne 1 ]]; then # Save "1" so we upload the first image. # saveImage.sh will write ${IMG_UPLOAD_FREQUENCY} to the file as needed. - echo "1" > "${FREQUENCY_FILE}" + echo "1" > "${FREQUENCY_FILE}" # FREQUENCY_FILE is global else rm -f "${FREQUENCY_FILE}" fi CAPTURE="capture_${CAMERA_TYPE}" -rm -f "${ALLSKY_NOTIFICATION_LOG}" # clear out any notificatons from prior runs. - # Clear up any flow timings activate_python_venv python3 "${ALLSKY_SCRIPTS}/flow-runner.py" --cleartimings deactivate_python_venv -# Run the main program - this is the main attraction... -# -cmd needs to come first since the capture_RPi code checks for it first. It's ignored -# in capture_ZWO. -# Pass debuglevel on command line so the capture program knows if it should display debug output. -"${ALLSKY_BIN}/${CAPTURE}" -cmd "${RPi_COMMAND_TO_USE}" -debuglevel "${ALLSKY_DEBUG_LEVEL}" -config "${ARGS_FILE}" +function catch_signal() { return 0; } +trap "catch_signal" SIGTERM SIGINT SIGHUP + +set_allsky_status "${ALLSKY_STATUS_STARTING}" + +# Run the camera-specific capture program - this is the main attraction... +CAMERA_NUMBER="$( settings ".cameranumber" )" +CAMERA_NUMBER="${CAMERA_NUMBER:-0}" # default +"${ALLSKY_BIN}/${CAPTURE}" \ + -debuglevel "${ALLSKY_DEBUG_LEVEL}" \ + -cmd "${RPi_COMMAND_TO_USE}" \ + -cameramodel "${CAMERA_MODEL}" \ + -cameranumber "${CAMERA_NUMBER}" \ + -locale "$( settings ".locale" )" \ + -config "${ARGS_FILE}" RETCODE=$? -[[ ${RETCODE} -eq ${EXIT_OK} ]] && doExit "${EXIT_OK}" "" +if [[ ${RETCODE} -eq ${EXIT_OK} ]]; then + [[ ${CAMERA_TYPE} == "ZWO" ]] && rm -f "${RESETTING_USB_LOG}" + set_allsky_status "${ALLSKY_STATUS_STOPPED}" + doExit "${EXIT_OK}" "" +fi if [[ ${RETCODE} -eq ${EXIT_RESTARTING} ]]; then - if [[ ${ON_TTY} -eq 1 ]]; then + if [[ ${ON_TTY} == "true" ]]; then echo "*** Can restart allsky now. ***" NOTIFICATION_TYPE="NotRunning" else NOTIFICATION_TYPE="Restarting" fi + set_allsky_status "${ALLSKY_STATUS_STOPPED}" doExit 0 "${NOTIFICATION_TYPE}" # use 0 so the service is restarted fi if [[ ${RETCODE} -eq ${EXIT_RESET_USB} ]]; then - # Reset the USB bus if possible - if [[ ${UHUBCTL_PATH} != "" ]]; then - reset_usb " (ASI_ERROR_TIMEOUTs)" - if [[ ${ON_TTY} -eq 1 ]]; then - echo "*** USB bus was reset; You can restart allsky now. ***" - NOTIFICATION_TYPE="NotRunning" - else - NOTIFICATION_TYPE="Restarting" - fi - if [[ ${USE_NOTIFICATION_IMAGES} -eq 1 ]]; then - "${ALLSKY_SCRIPTS}/copy_notification_image.sh" "${NOTIFICATION_TYPE}" - fi - doExit 0 "" # use 0 so the service is restarted + # Reset the USB bus + reset_usb " (too many capture errors)" + if [[ ${ON_TTY} == "true" ]]; then + echo "*** USB bus was reset; You can restart allsky now. ***" + NOTIFICATION_TYPE="NotRunning" else - # TODO: use ASI_ERROR_TIMEOUT message - MSG="Non-recoverable ERROR found" - [[ ${ON_TTY} -eq 1 ]] && echo "*** ${MSG} - ${SEE_LOG_MSG}. ***" - doExit "${EXIT_ERROR_STOP}" "Error" \ - "${ERROR_MSG_PREFIX}Too many\nASI_ERROR_TIMEOUT\nerrors received!\n${SEE_LOG_MSG}" \ - "${STOPPED_MSG}
${MSG}
${SEE_LOG_MSG}." + NOTIFICATION_TYPE="Restarting" + fi + if [[ ${USE_NOTIFICATION_IMAGES} == "true" ]]; then + "${ALLSKY_SCRIPTS}/copy_notification_image.sh" "${NOTIFICATION_TYPE}" fi + set_allsky_status "${ALLSKY_STATUS_ERROR}" + doExit 0 "" # use 0 so the service is restarted fi # RETCODE -ge ${EXIT_ERROR_STOP} means we should not restart until the user fixes the error. if [[ ${RETCODE} -ge ${EXIT_ERROR_STOP} ]]; then echo "***" - if [[ ${ON_TTY} -eq 1 ]]; then - echo "*** After fixing, restart ${ME}.sh. ***" + if [[ ${ON_TTY} == "true" ]]; then + echo "*** After fixing, restart ${ME}. ***" else echo "*** After fixing, restart the allsky service. ***" fi echo "***" - doExit "${EXIT_ERROR_STOP}" "Error" # Can't do a custom message since we don't know the problem + set_allsky_status "${ALLSKY_STATUS_ERROR}" + doExit "${RETCODE}" "Error" # Can't do a custom message since we don't know the problem fi # Some other error -if [[ ${USE_NOTIFICATION_IMAGES} -eq 1 ]]; then +if [[ ${USE_NOTIFICATION_IMAGES} == "true" ]]; then # If started by the service, it will restart us once we exit. + set_allsky_status "${ALLSKY_STATUS_NOT_RUNNING}" doExit "${RETCODE}" "NotRunning" else + set_allsky_status "${ALLSKY_STATUS_SEE_WEBUI}" doExit "${RETCODE}" "" fi diff --git a/assets/TestPlan.xlsx b/assets/TestPlan.xlsx new file mode 100644 index 000000000..53294aaf6 Binary files /dev/null and b/assets/TestPlan.xlsx differ diff --git a/assets/image.psd b/assets/image.psd new file mode 100644 index 000000000..4eda6edd2 Binary files /dev/null and b/assets/image.psd differ diff --git a/config_repo/Makefile b/config_repo/Makefile index 6d43ec165..2ab73a353 100644 --- a/config_repo/Makefile +++ b/config_repo/Makefile @@ -24,7 +24,7 @@ ifeq ($(PKGBUILD),) endif -CONFIGFILES := config.sh ftp-settings.sh +ENVFILE := env.json UNINSTALLFILES := $(DESTDIR)$(sysconfdir)/logrotate.d/allsky $(DESTDIR)$(sysconfdir)/rsyslog.d/allsky.conf $(DESTDIR)$(sysconfdir)/systemd/system/allsky.service $(DESTDIR)$(sysconfdir)/systemd/system/allskyperiodic.service %: @@ -42,7 +42,6 @@ uninstall: @echo `date +%F\ %R:%S` Complete. @echo `date +%F\ %R:%S` NOTE: Config files were \-NOT\- removed. @echo `date +%F\ %R:%S` To remove config files, please run \'sudo make remove_configs\' - .PHONY : uninstall ifeq ($(PKGBUILD),1) @@ -57,17 +56,20 @@ createDirs: @if [ ! -e $(DESTDIR)$(sysconfdir)/profile.d ]; then mkdir -p $(DESTDIR)$(sysconfdir)/profile.d; fi @if [ ! -e $(DESTDIR)$(sysconfdir)/systemd/system ]; then mkdir -p $(DESTDIR)$(sysconfdir)/systemd/system; fi @if [ ! -e $(DESTDIR)$(sysconfdir)/udev/rules.d ]; then mkdir -p $(DESTDIR)$(sysconfdir)/udev/rules.d; fi - .PHONY : createDirs -$(CONFIGFILES): +$(ENVFILE): @if [ ! -e $(DESTDIR)$(sysconfdir)/allsky/$@ ]; then \ echo `date +%F\ %R:%S` Copying default $@; \ - install -m 0644 $@.repo $(DESTDIR)$(sysconfdir)/allsky/$@; \ + sed -e "s|XX_HOME_XX|$(HOMEDIR)|" $@.repo > env; \ + install -m 0664 env $(HOMEDIR)/$@; \ + rm -f env; \ fi -.PHONY : $(CONFIGFILES) +# TODO: In PKGBUILD mode, where is the "allsky" directory? $(ENVFILE) needs to go in it. +# Is $(HOMEDIR) the correct location, or should it be $(DESTDIR)$(sysconfdir)/allsky ? +.PHONY : $(ENVFILE) -install: createDirs $(CONFIGFILES) +install: createDirs $(ENVFILE) @echo `date +%F\ %R:%S` Setting up udev rules... @install -D -m 0644 asi.rules $(DESTDIR)$(sysconfdir)/udev/rules.d/ @echo `date +%F\ %R:%S` Setting up logging... @@ -86,22 +88,14 @@ else # Not in package build mode ###################################### remove_configs: @echo `date +%F\ %R:%S` Removing config path and files ../config @rm -rf ../config - .PHONY : remove_configs createDirs: @echo `date +%F\ %R:%S` Creating directory structures... @if [ ! -e ../config ]; then mkdir -p ../config; chown $(SUDO_USER):$(SUDO_USER) ../config; fi - .PHONY : createDirs -$(CONFIGFILES): - @if [ ! -e ../config/$@ ]; then \ - echo `date +%F\ %R:%S` Copying default $@; \ - install -m 0644 -o $(SUDO_USER) -g $(SUDO_USER) $@.repo ../config/$@; \ - fi -.PHONY : $(CONFIGFILES) -install: createDirs $(CONFIGFILES) +install: createDirs @echo `date +%F\ %R:%S` Setting up udev rules... @install -D -m 0644 asi.rules $(DESTDIR)$(sysconfdir)/udev/rules.d/ @echo `date +%F\ %R:%S` Setting up logging... diff --git a/config_repo/RPi_cameraInfo.txt.repo b/config_repo/RPi_cameraInfo.txt.repo new file mode 100644 index 000000000..a5f93039d --- /dev/null +++ b/config_repo/RPi_cameraInfo.txt.repo @@ -0,0 +1,216 @@ +# TODO: This file should be JSON, but that requires a .cpp library that reads json. + +# Each supported camera has three sections: +# 1. A single line for general camera info per ASI_CAMERA_INFO ASICameraInfoArray[]. +# 2. Multiple lines for ASI_CONTROL_CAPS ControlCapsArray[][] line for libcamera-still / rpicam-still. +# The last line for this camera begins with "End". +# 3. Multiple lines for ASI_CONTROL_CAPS ControlCapsArray[][] line for raspistill. +# The last line for this camera begins with "End". +# If there's only one line that camera isn't supported with raspistill. + +# ASI_CAMERA_INFO ASICameraInfoArray[]: +# Module (sensor), Module_len, Name, CameraID, MaxHeight, MaxWidth, IsColorCam, +# BayerPattern, SupportedBins, SupportedVideoFormat, PixelSize, IsCoolerCam, +# BitDepth, SupportsTemperature, SupportAutoFocus + +# "Name" must be such that a filename with ${NAME} in it works, e.g., no "/". + +# ASI_CONTROL_CAPS ControlCapsArray[][]: +# Name, Description, MaxValue, MinValue, DefaultValue, CurrentValue, +# IsAutoSupported, IsWritable, ControlType + +# All data lines are tab-separated and empty lines and lines that begin with # are ignored. +# The first field in every data entry lists what type of line it is: +# camera, libcamera, raspistill + +camera imx477 0 HQ 0 3040 4056 ASI_TRUE BAYER_RG 1 2 0 ASI_IMG_RGB24 ASI_IMG_END 1.55 ASI_FALSE 12 ASI_TRUE ASI_FALSE + +# libcamera +libcamera Gain Gain 16.0 1.0 1.0 NOT_SET ASI_TRUE ASI_TRUE ASI_GAIN +libcamera Exposure Exposure Time (us) 694434742 114 10000 NOT_SET ASI_TRUE ASI_TRUE ASI_EXPOSURE +libcamera WB_R White balance: Red component 32.0 0.0 2.5 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_R +libcamera WB_B White balance: Blue component 32.0 0.0 2.0 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_B +libcamera Flip Flip: 0->None 1->Horiz 2->Vert 3->Both 3 0 0 NOT_SET ASI_FALSE ASI_TRUE ASI_FLIP +libcamera AutoExpMaxGain Auto exposure maximum gain value 16.0 1.0 16.0 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_GAIN +libcamera AutoExpMaxExpMS Auto exposure maximum exposure value (ms) 230000 1 60000 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_EXP +libcamera ExposureCompensation Exposure Compensation 8.0 -8.0 0.0 NOT_SET ASI_FALSE ASI_TRUE EV +libcamera Saturation Saturation 32.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SATURATION +libcamera Contrast Contrast 32.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE CONTRAST +libcamera Sharpness Sharpness 16.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SHARPNESS +libcamera End + +raspistill Gain Gain 16.0 1.0 1.0 NOT_SET ASI_TRUE ASI_TRUE ASI_GAIN +raspistill Exposure Exposure Time (us) 230000000 114 10000 NOT_SET ASI_TRUE ASI_TRUE ASI_EXPOSURE +raspistill WB_R White balance: Red component 10.0 0.1 2.5 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_R +raspistill WB_B White balance: Blue component 10.0 0.1 2.0 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_B +raspistill Flip Flip: 0->None 1->Horiz 2->Vert 3->Both 3 0 0 NOT_SET ASI_FALSE ASI_TRUE ASI_FLIP +raspistill AutoExpMaxGain Auto exposure maximum gain value 16.0 1.0 16.0 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_GAIN +raspistill AutoExpMaxExpMS Auto exposure maximum exposure value (ms) 230000 1 60000 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_EXP +raspistill ExposureCompensation Exposure Compensation 10 -10 0.0 NOT_SET ASI_FALSE ASI_TRUE EV +raspistill Saturation Saturation 100 -100 0 NOT_SET ASI_FALSE ASI_TRUE SATURATION +raspistill Contrast Contrast 100 -100 0 NOT_SET ASI_FALSE ASI_TRUE CONTRAST +raspistill Sharpness Sharpness 100 -100 0 NOT_SET ASI_FALSE ASI_TRUE SHARPNESS +raspistill End + + +# There are many versions of the imx708 (_wide, _wide_noir, _noir, etc.) so just +# check for imx708 (6 characters). +camera imx708 6 Module 3 0 2592 4608 ASI_TRUE BAYER_RG 1 2 0 ASI_IMG_RGB24 ASI_IMG_END 1.4 ASI_FALSE 10 ASI_TRUE ASI_TRUE + +libcamera Gain Gain 16.0 1.122807 1.122807 NOT_SET ASI_TRUE ASI_TRUE ASI_GAIN +libcamera Exposure Exposure Time (us) 112015553 26 10000 NOT_SET ASI_TRUE ASI_TRUE ASI_EXPOSURE +libcamera WB_R White balance: Red component 32.0 0.0 2.5 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_R +libcamera WB_B White balance: Blue component 32.0 0.0 2.0 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_B +libcamera Flip Flip: 0->None 1->Horiz 2->Vert 3->Both 3 0 0 NOT_SET ASI_FALSE ASI_TRUE ASI_FLIP +libcamera AutoExpMaxGain Auto exposure maximum gain value 16.0 1.122807 16.0 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_GAIN +libcamera AutoExpMaxExpMS Auto exposure maximum exposure value (ms) 112015553 / US_IN_MS 26.0 60000 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_EXP +libcamera ExposureCompensation Exposure Compensation 8.0 -8.0 0.0 NOT_SET ASI_FALSE ASI_TRUE EV +libcamera Saturation Saturation 32.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SATURATION +libcamera Contrast Contrast 32.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE CONTRAST +libcamera Sharpness Sharpness 32.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SHARPNESS +libcamera End + +raspistill End + + +camera ov5647 0 Version 1 0 1944 2592 ASI_TRUE BAYER_RG 1 2 0 ASI_IMG_RGB24 ASI_IMG_END 1.4 ASI_FALSE 10 ASI_FALSE ASI_FALSE + +libcamera Gain Gain 63.9375 1.0 1.0 NOT_SET ASI_TRUE ASI_TRUE ASI_GAIN +libcamera Exposure Exposure Time (us) 969249 130 9000 NOT_SET ASI_TRUE ASI_TRUE ASI_EXPOSURE +libcamera WB_R White balance: Red component 32.0 0.0 0.0 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_R +libcamera WB_B White balance: Blue component 32.0 0.0 0.0 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_B +libcamera Flip Flip: 0->None 1->Horiz 2->Vert 3->Both 3 0 0 NOT_SET ASI_FALSE ASI_TRUE ASI_FLIP +libcamera AutoExpMaxGain Auto exposure maximum gain value 63.9375 1.0 63.9375 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_GAIN +libcamera AutoExpMaxExpMS Auto exposure maximum exposure value (ms) 969249 / US_IN_MS 1.0 9000 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_EXP +libcamera ExposureCompensation Exposure Compensation 8.0 -8.0 0.0 NOT_SET ASI_FALSE ASI_TRUE EV +libcamera Saturation Saturation 32 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SATURATION +libcamera Contrast Contrast 32.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE CONTRAST +libcamera Sharpness Sharpness 16.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SHARPNESS +libcamera End + +raspistill End + + +camera imx290 0 imx290 60.00 fps 0 1080 1920 ASI_TRUE BAYER_RG 1 2 0 ASI_IMG_RGB24 ASI_IMG_END 2.9 ASI_FALSE 12 ASI_FALSE ASI_FALSE + +libcamera Gain Gain 16.0 1.0 1.0 NOT_SET ASI_TRUE ASI_TRUE ASI_GAIN +libcamera Exposure Exposure Time (us) 200000000 1 10000 NOT_SET ASI_TRUE ASI_TRUE ASI_EXPOSURE +libcamera WB_R White balance: Red component 10.0 0.1 2.5 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_R +libcamera WB_B White balance: Blue component 10.0 0.1 2.0 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_B +libcamera Flip Flip: 0->None 1->Horiz 2->Vert 3->Both 3 0 0 NOT_SET ASI_FALSE ASI_TRUE ASI_FLIP +libcamera AutoExpMaxGain Auto exposure maximum gain value 16.0 1.0 16.0 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_GAIN +libcamera AutoExpMaxExpMS Auto exposure maximum exposure value (ms) 200000 1 60000 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_EXP +libcamera ExposureCompensation Exposure Compensation 10.0 -10.0 0.0 NOT_SET ASI_FALSE ASI_TRUE EV +libcamera Saturation Saturation 15.99 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SATURATION +libcamera Contrast Contrast 15.99 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE CONTRAST +libcamera Sharpness Sharpness 15.99 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SHARPNESS +libcamera End + +raspistill End + + +camera imx519 0 Arducam 16 MP 0 3496 4656 ASI_TRUE BAYER_RG 1 2 0 ASI_IMG_RGB24 ASI_IMG_END 1.22 ASI_FALSE 10 ASI_FALSE ASI_TRUE + +libcamera Gain Gain 16.0 1.0 1.0 NOT_SET ASI_TRUE ASI_TRUE ASI_GAIN +libcamera Exposure Exposure Time (us) 200000000 1 10000 NOT_SET ASI_TRUE ASI_TRUE ASI_EXPOSURE +libcamera WB_R White balance: Red component 32.0 0.1 2.5 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_R +libcamera WB_B White balance: Blue component 32.0 0.1 2.0 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_B +libcamera Flip Flip: 0->None 1->Horiz 2->Vert 3->Both 3 0 0 NOT_SET ASI_FALSE ASI_TRUE ASI_FLIP +libcamera AutoExpMaxGain Auto exposure maximum gain value 16.0 1.0 16.0 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_GAIN +libcamera AutoExpMaxExpMS Auto exposure maximum exposure value (ms) 200000 1 60000 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_EXP +libcamera ExposureCompensation Exposure Compensation 10.0 -10.0 0.0 NOT_SET ASI_FALSE ASI_TRUE EV +libcamera Saturation Saturation 32.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SATURATION +libcamera Contrast Contrast 32.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE CONTRAST +libcamera Sharpness Sharpness 16.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SHARPNESS +libcamera End + +raspistill End + + +camera arducam_64mp 0 Arducam 64 MP 0 6944 9152 ASI_TRUE BAYER_RG 1 2 0 ASI_IMG_RGB24 ASI_IMG_END 0.8 ASI_FALSE 10 ASI_FALSE ASI_TRUE + +libcamera Exposure Exposure Time (us) 200000000 1 10000 NOT_SET ASI_TRUE ASI_TRUE ASI_EXPOSURE +libcamera WB_R White balance: Red component 10.0 0.1 2.5 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_R +libcamera WB_B White balance: Blue component 10.0 0.1 2.0 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_B +libcamera Flip Flip: 0->None 1->Horiz 2->Vert 3->Both 3 0 0 NOT_SET ASI_FALSE ASI_TRUE ASI_FLIP +libcamera AutoExpMaxGain Auto exposure maximum gain value 16.0 1.0 16.0 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_GAIN +libcamera AutoExpMaxExpMS Auto exposure maximum exposure value (ms) 200000 1 60000 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_EXP +libcamera ExposureCompensation Exposure Compensation 10.0 -10.0 0.0 NOT_SET ASI_FALSE ASI_TRUE EV +libcamera Saturation Saturation 15.99 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SATURATION +libcamera Contrast Contrast 15.99 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE CONTRAST +libcamera Sharpness Sharpness 15.99 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SHARPNESS +libcamera End + +raspistill End + + +camera arducam-pivariety 0 Arducam 462 0 1080 1920 ASI_TRUE BAYER_RG 1 2 0 ASI_IMG_RGB24 ASI_IMG_END 2.9 ASI_FALSE 10 ASI_FALSE ASI_TRUE + +libcamera Gain Gain 200.0 1.0 1.33 NOT_SET ASI_TRUE ASI_TRUE ASI_GAIN +libcamera Exposure Exposure Time (us) 15500000 14 10000000 NOT_SET ASI_TRUE ASI_TRUE ASI_EXPOSURE +libcamera WB_R White balance: Red component 32.0 0.0 1.0 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_R +libcamera WB_B White balance: Blue component 32.0 0.0 1.0 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_B +libcamera Flip Flip: 0->None 1->Horiz 2->Vert 3->Both 3 0 0 NOT_SET ASI_FALSE ASI_TRUE ASI_FLIP +libcamera AutoExpMaxGain Auto exposure maximum gain value 200.0 1.0 200.0 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_GAIN +libcamera AutoExpMaxExpMS Auto exposure maximum exposure value (ms) 15.5000 1 15.5000 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_EXP +libcamera ExposureCompensation Exposure Compensation 8.0 -8.0 0.0 NOT_SET ASI_FALSE ASI_TRUE EV +libcamera Saturation Saturation 32.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SATURATION +libcamera Contrast Contrast 32.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE CONTRAST +libcamera Sharpness Sharpness 16.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SHARPNESS +libcamera End + +raspistill End + + +camera imx219 0 Waveshare imx219-d160 0 2464 3280 ASI_TRUE BAYER_RG 1 2 0 ASI_IMG_RGB24 ASI_IMG_END 1.12 ASI_FALSE 10 ASI_FALSE ASI_FALSE + +libcamera Gain Gain 10.666667 1.0 1.0 NOT_SET ASI_TRUE ASI_TRUE ASI_GAIN +libcamera Exposure Exposure Time (us) 11767556.0 75 10000000 NOT_SET ASI_TRUE ASI_TRUE ASI_EXPOSURE +libcamera WB_R White balance: Red component 32.0 0.0 1.0 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_R +libcamera WB_B White balance: Blue component 32.0 0.0 1.0 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_B +libcamera Flip Flip: 0->None 1->Horiz 2->Vert 3->Both 3 0 0 NOT_SET ASI_FALSE ASI_TRUE ASI_FLIP +libcamera AutoExpMaxGain Auto exposure maximum gain value 10.666667 1.0 10.666667 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_GAIN +libcamera AutoExpMaxExpMS Auto exposure maximum exposure value (ms) 11767.556000 1 11767.556000 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_EXP +libcamera ExposureCompensation Exposure Compensation 8.0 -8.0 0.0 NOT_SET ASI_FALSE ASI_TRUE EV +libcamera Saturation Saturation 32.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SATURATION +libcamera Contrast Contrast 32.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE CONTRAST +libcamera Sharpness Sharpness 16.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SHARPNESS +libcamera End + +raspistill End + + +camera ov64a40 0 Arducam 64MP Owlsight 0 6944 9248 ASI_TRUE BAYER_BG 1 2 0 ASI_IMG_RGB24 ASI_IMG_END 1.008 ASI_FALSE 10 ASI_FALSE ASI_TRUE + +libcamera Gain Gain 15.992188 1.0 1.0 NOT_SET ASI_TRUE ASI_TRUE ASI_GAIN +libcamera Exposure Exposure Time (us) 608453664 580 10000000 NOT_SET ASI_TRUE ASI_TRUE ASI_EXPOSURE +libcamera WB_R White balance: Red component 32.0 0.0 1.0 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_R +libcamera WB_B White balance: Blue component 32.0 0.0 1.0 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_B +libcamera Flip Flip: 0->None 1->Horiz 2->Vert 3->Both 3 0 0 NOT_SET ASI_FALSE ASI_TRUE ASI_FLIP +libcamera AutoExpMaxGain Auto exposure maximum gain value 15.992188 1.0 1.0 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_GAIN +libcamera AutoExpMaxExpMS Auto exposure maximum exposure value (ms) 608454 580 60000 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_EXP +libcamera ExposureCompensation Exposure Compensation 8.0 -8.0 0.0 NOT_SET ASI_FALSE ASI_TRUE EV +libcamera Saturation Saturation 32.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SATURATION +libcamera Contrast Contrast 32.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE CONTRAST +libcamera Sharpness Sharpness 16.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SHARPNESS +libcamera End + +raspistill End + + +camera imx283 0 OneInchEye IMX283 0 3648 5472 ASI_TRUE BAYER_RG 1 2 0 ASI_IMG_RGB24 ASI_IMG_END 2.4 ASI_FALSE 12 ASI_FALSE ASI_FALSE + +libcamera Gain Gain 22.505495 1.0 4.0 NOT_SET ASI_TRUE ASI_TRUE ASI_GAIN +libcamera Exposure Exposure Time (us) 129373756 58 10000000 NOT_SET ASI_TRUE ASI_TRUE ASI_EXPOSURE +libcamera WB_R White balance: Red component 32.0 0.0 1.0 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_R +libcamera WB_B White balance: Blue component 32.0 0.0 1.0 NOT_SET ASI_TRUE ASI_TRUE ASI_WB_B +libcamera Flip Flip: 0->None 1->Horiz 2->Vert 3->Both 3 0 0 NOT_SET ASI_FALSE ASI_TRUE ASI_FLIP +libcamera AutoExpMaxGain Auto exposure maximum gain value 22.505495 1.0 4.0 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_GAIN +libcamera AutoExpMaxExpMS Auto exposure maximum exposure value (ms) 129374 0.058 60000 NOT_SET ASI_FALSE ASI_TRUE ASI_AUTO_MAX_EXP +libcamera ExposureCompensation Exposure Compensation 8.0 -8.0 0.0 NOT_SET ASI_FALSE ASI_TRUE EV +libcamera Saturation Saturation 32.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SATURATION +libcamera Contrast Contrast 32.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE CONTRAST +libcamera Sharpness Sharpness 16.0 0.0 1.0 NOT_SET ASI_FALSE ASI_TRUE SHARPNESS +libcamera End + +raspistill End diff --git a/config_repo/allskyDefines.inc.repo b/config_repo/allskyDefines.inc.repo index 5e26dfb48..2c1978ed9 100644 --- a/config_repo/allskyDefines.inc.repo +++ b/config_repo/allskyDefines.inc.repo @@ -1,12 +1,14 @@ "; - echo ""; + echo "
"; echo "Please run the following from the 'allsky' directory before using the WebUI:"; - echo ""; - echo " ./install.sh --update"; + echo "

"; + echo " ./install.sh --fix"; echo "
"; - exit; + exit(1); } - ?> diff --git a/config_repo/config.sh.repo b/config_repo/config.sh.repo deleted file mode 100644 index 3af41ed81..000000000 --- a/config_repo/config.sh.repo +++ /dev/null @@ -1,199 +0,0 @@ -#!/bin/bash - -# X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*XX*X*X*X*X*X*X - -# For details on these settings, click on the "Allsky Documentation" link in the WebUI, -# then click on the "Settings -> Allsky" link, -# then, in the "Editor WebUI Page" section, open the "config.sh" sub-section. - -# X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*XX*X*X*X*X*X*X - - -########## Images -# Set to "true" to upload the current image to your website. -IMG_UPLOAD="false" - -# Upload the image file as "image-YYYYMMDDHHMMSS.jpg" (true) or "image.jpg" (false). -IMG_UPLOAD_ORIGINAL_NAME="false" - -# If IMG_UPLOAD is "true", upload images every IMG_UPLOAD_FREQUENCY frames, e.g., every 5 frames. -# 1 uploades every frame. -IMG_UPLOAD_FREQUENCY=1 - -# Resize images before cropping, stretching, and saving. -IMG_RESIZE="false" -IMG_WIDTH=2028 -IMG_HEIGHT=1520 - -# Crop images before stretching and saving. -CROP_IMAGE="false" -CROP_WIDTH=640 -CROP_HEIGHT=480 -CROP_OFFSET_X=0 -CROP_OFFSET_Y=0 - -# Auto stretch images saved at night. The numbers below are good defaults. -AUTO_STRETCH="false" -AUTO_STRETCH_AMOUNT=10 -AUTO_STRETCH_MID_POINT="10%" - -# Resize uploaded images. Change the size to fit your sensor. -RESIZE_UPLOADS="false" -RESIZE_UPLOADS_WIDTH=962 -RESIZE_UPLOADS_HEIGHT=720 - -# Create thumbnails of images. If you never look at them, consider changing this to "false". -IMG_CREATE_THUMBNAILS="true" - -# Remove corrupt or too dim/bright images. -REMOVE_BAD_IMAGES="true" -REMOVE_BAD_IMAGES_THRESHOLD_LOW=1 -REMOVE_BAD_IMAGES_THRESHOLD_HIGH=90 - - -########## Timelapse -# Set to "true" to generate a timelapse video at the end of each night. -TIMELAPSE="true" - -# Set the resolution in pixels of the timelapse video. -TIMELAPSEWIDTH=0 -TIMELAPSEHEIGHT=0 - -# Bitrate of the timelapse video. -TIMELAPSE_BITRATE="5000k" - -# Timelapse video Frames Per Second. -FPS=25 - -# Encoder for timelapse video. -VCODEC="libx264" - -# Pixel format. -PIX_FMT="yuv420p" - -# Amount of information displayed while creating a timelapse video. -FFLOG="warning" - -# Set to "true" to keep the list of files used in creating the timelapse video. -KEEP_SEQUENCE="false" - -# Any additional timelapse parameters. Run "ffmpeg -?" to see the options. -TIMELAPSE_EXTRA_PARAMETERS="" - -# Set to "true" to upload the timelapse video to your website at the end of each night. -UPLOAD_VIDEO="false" - -# Set to "true" to upload the timelapse video's thumbnail to your website at the end of each night. -TIMELAPSE_UPLOAD_THUMBNAIL="true" - -###### Mini-timelapse -# The number of images you want in the mini-timelapse. 0 disables mini-timelapse creation. -TIMELAPSE_MINI_IMAGES=0 - -# Should a mini-timelapse be created even if ${TIMELAPSE_MINI_IMAGES} haven't been captured yet? -TIMELAPSE_MINI_FORCE_CREATION="false" - -# After how many images should the mini-timelapse be made? -# If you have a slow Pi or short delays between images, -# set this to a higher number (i.e., not as often). -TIMELAPSE_MINI_FREQUENCY=5 - -# The remaining TIMELAPSE_MINI_* variables serve the same function as the daily timelapse. -TIMELAPSE_MINI_UPLOAD_VIDEO="true" -TIMELAPSE_MINI_UPLOAD_THUMBNAIL="true" -TIMELAPSE_MINI_FPS=5 -TIMELAPSE_MINI_BITRATE="2000k" -TIMELAPSE_MINI_WIDTH=1014 -TIMELAPSE_MINI_HEIGHT=760 - - -########## Keogram -# Set to "true" to generate a keogram at the end of each night. -KEOGRAM="true" - -# Additional Keogram parameters. -KEOGRAM_EXTRA_PARAMETERS="--font-size 1.0 --font-line 1 --font-color '255 255 255'" - -# Set to "true" to upload the keogram image to your website at the end of each night. -UPLOAD_KEOGRAM="false" - - -########## Startrails -# Set to "true" to generate a startrails image of each night. -STARTRAILS="true" - -# Images with a brightness higher than this threshold will be skipped for -# startrails image generation. -BRIGHTNESS_THRESHOLD=0.1 - -# Any additional startrails parameters. -STARTRAILS_EXTRA_PARAMETERS="" - -# Set to "true" to upload the startrails image to your website at the end of each night. -UPLOAD_STARTRAILS="false" - - -########## Other -# Size of thumbnails. -THUMBNAIL_SIZE_X=100 -THUMBNAIL_SIZE_Y=75 - -# Set this value to the number of days images plus videos you want to keep. -# Set to 0 to keep ALL days. -DAYS_TO_KEEP=14 - -# Same as DAYS_TO_KEEP, but for the Allsky Website, if installed. -WEB_DAYS_TO_KEEP=0 - -# See the documentation for a description of this setting. -WEBUI_DATA_FILES="" - -# See the documentation for a description of these settings. -UHUBCTL_PATH="" -UHUBCTL_PORT=2 - - -# ================ DO NOT CHANGE ANYTHING BELOW THIS LINE ================ -ME2="$(basename "${BASH_SOURCE[0]}")" - -# CAMERA_TYPE is updated during installation -CAMERA_TYPE="" -if [ "${CAMERA_TYPE}" = "" ]; then - echo -e "${RED}${ME2}: ERROR: Please set 'Camera Type' in the WebUI.${NC}" - sudo systemctl stop allsky > /dev/null 2>&1 - exit ${EXIT_ERROR_STOP} -fi - -IMG_DIR="current/tmp" -CAPTURE_SAVE_DIR="${ALLSKY_TMP}" - -# Don't try to upload a mini-timelapse if they aren't using them. -if [[ ${TIMELAPSE_MINI_IMAGES} -eq 0 ]]; then - TIMELAPSE_MINI_UPLOAD_VIDEO="false" - TIMELAPSE_MINI_UPLOAD_THUMBNAIL="false" -fi - -if [[ -z ${SETTINGS_FILE} ]]; then # SETTINGS_FILE is defined in variables.sh - echo -e "${RED}${ME2}: ERROR: SETTINGS_FILE variable not defined!${NC}" - echo -e "${RED}Make sure 'variables.sh' is source'd in!${NC}" - return 1 -fi -if [[ ! -f ${SETTINGS_FILE} ]]; then - echo -e "${RED}${ME2}: ERROR: Settings file '${SETTINGS_FILE}' not found!${NC}" - sudo systemctl stop allsky > /dev/null 2>&1 - exit ${EXIT_ERROR_STOP} -fi - -# Get the name of the file the websites will look for, and split into name and extension. -FULL_FILENAME="$(settings ".filename")" -EXTENSION="${FULL_FILENAME##*.}" -FILENAME="${FULL_FILENAME%.*}" - -CAMERA_MODEL="$(settings '.cameraModel')" - -# So scripts can conditionally output messages. -ALLSKY_DEBUG_LEVEL="$(settings '.debuglevel')" -# ALLSKY_VERSION is updated during installation -ALLSKY_VERSION="XX_ALLSKY_VERSION_XX" - -CONFIG_SH_VERSION=1 diff --git a/config_repo/configuration.json.repo b/config_repo/configuration.json.repo index 71b01a050..fb2cd2c1b 100644 --- a/config_repo/configuration.json.repo +++ b/config_repo/configuration.json.repo @@ -1,233 +1,225 @@ { - "comment": "See https://github.com/AllskyTeam/allsky/wiki/allsky-website-Settings for a description of these settings.", - "config": { - "comment": "These settings impact what appears in the popout and the constellation overlay", - "imageName": "/current/tmp/image.jpg", - "location": "XX_NEED_TO_UPDATE_XX", - "latitude": "XX_NEED_TO_UPDATE_XX", - "longitude": "XX_NEED_TO_UPDATE_XX", - "camera": "XX_NEED_TO_UPDATE_XX", - "lens": "XX_NEED_TO_UPDATE_XX", - "computer": "XX_NEED_TO_UPDATE_XX", - "owner": "XX_NEED_TO_UPDATE_XX", - "auroraForecast": false, - "auroraMap": "XX_NEED_TO_UPDATE_XX", - "intervalSeconds": 5, - "showOverlayAtStartup": false, - "overlayWidth": 875, - "overlayHeight": 875, - "overlayOffsetLeft": 0, - "overlayOffsetTop": 0, - "az": 0, - "imageWidth": 900, - "opacity": 0.5, - "objectsComment": "If you want the Messier objects on the overlay, remove the 'XXX_' below.", - "XXX_objects": "virtualsky/messier.json", - "meridian": false, - "ecliptic": false, - "fontsize": "14px", - "cardinalpoints": true, - "cardinalpoints_fontsize": "18px", - "showstarlabels": true, - "projection": "fisheye", - "constellations": true, - "constellationwidth": 0.75, - "constellationlabels": false, - "constellationboundaries": false, - "constellationboundarieswidth": 0.75, - "gridlines_eq": false, - "gridlineswidth": 0.75, - "showgalaxy": false, - "galaxywidth": 0.75, - "mouse": false, - "keyboard": true, - "showdate": false, - "showposition": false, - "sky_gradient": false, - "gradient": false, - "transparent": true, - "lang": "en", - "colours" : { - "comment": "See the Wiki for how to change colors on the constellation overlay.", - "normal" : { - "XXX_cardinal": "rgba(0,255,0,1)" - }, - "negative" : { - "XXX_cardinal": "rgba(255,255,255,1)" - } - }, - "live": true, - "id": "starmap", - "AllskyVersion" : "XX_ALLSKY_VERSION_XX", - "AllskyWebsiteVersion" : "XX_ALLSKY_WEBSITE_VERSION_XX" - }, - "homePage": { - "comment": "These settings impact the look and feel of the Website home page.", - "onPi": true, - "title": "XX_NEED_TO_UPDATE_XX", - "og_description": "XX_NEED_TO_UPDATE_XX", - "backgroundImage": { - "url": "", - "style": "" - }, - "loadingImage": "loading.jpg", - "imageBorder": false, - "includeGoogleAnalytics": false, - "includeLinkToMakeOwn": true, - "personalLink": { - "prelink": "", - "message": "", - "url": "", - "title": "", - "style": "" - }, - "og_type": "website", - "og_url": "http://www.thomasjacquin/allsky/", - "og_image": "image.jpg", - "favicon": "allsky-favicon.png", - "leftSidebar": [ - { - "comment": "Once you have modified the settings so the overlay fits, set 'display' to true.", - "display": false, - "title": "Show constellation overlay", - "icon": "fa fa-2x fa-fw allsky-constellation", - "other": "id='overlayBtn' ng-click='toggleOverlay()' ng-class=\"{'active': showOverlay}\"", - "style": "" - }, - { - "display": true, - "url": "videos", - "title": "Archived Timelapes", - "icon": "fa fa-2x fa-fw fa-play-circle", - "style": "" - }, - { - "display": false, - "url": "/current/tmp/mini-timelapse.mp4", - "title": "Mini-timelapse", - "icon": "fa fa-2x fa-fw icon-mini-timelapse", - "style": "" - }, - { - "display": true, - "url": "keograms", - "title": "Archived Keograms", - "icon": "fa fa-2x fa-fw fa-barcode", - "style": "" - }, - { - "display": true, - "url": "startrails", - "title": "Archived Startrails", - "icon": "fa fa-2x fa-fw fa-star", - "style": "" - }, - { - "display": true, - "variable": "imageName", - "title": "Full-sized image", - "icon": "fa fa-2x fa-fw fa-expand-arrows-alt", - "style": "" - }, - { - "display": true, - "title": "Display information about the camera and other settings", - "icon": "fa fa-2x fa-fw fa-info-circle", - "other": "ng-click='toggleInfo()' ng-class=\"{'active': showInfo}\"", - "style": "" - }, - { - "display": false, - "comment": "Add leftSidebar items above." - } - ], - "leftSidebarStyle": "", - "popoutIcons": [ - { - "display": true, - "label": "Location", - "icon": "fa fa-fw fa-map-marker-alt", - "variable": "location", - "value": "", - "style": "" - }, - { - "display": true, - "label": "Latitude", - "icon": "fa fa-fw fa-map-marker", - "variable": "s_latitude", - "value": "", - "style": "" - }, - { - "display": true, - "label": "Longitude", - "icon": "fa fa-fw fa-map-marker", - "variable": "s_longitude", - "value": "", - "style": "" - }, - { - "display": true, - "label": "Camera", - "icon": "fa fa-fw fa-camera-retro", - "variable": "camera", - "value": "", - "style": "" - }, - { - "display": true, - "label": "Lens", - "icon": "fa fa-fw fa-dot-circle", - "variable": "lens", - "value": "", - "style": "" - }, - { - "display": true, - "label": "Computer", - "icon": "fa fa-fw fa-microchip", - "variable": "computer", - "value": "", - "style": "" - }, - { - "display": true, - "label": "Owner", - "icon": "fa fa-fw fa-user", - "variable": "owner", - "value": "", - "style": "" - }, - { - "display": false, - "label": "Allsky Settings", - "icon": "fa fa-fw fa-cogs", - "variable": "", - "value": "Click to view", - "style": "" - }, - { - "display": true, - "label": "Allsky Version", - "icon": "fa fa-fw fa-file-alt", - "variable": "AllskyVersion", - "value": "", - "style": "" - }, - { - "display": true, - "label": "Website Version", - "icon": "fa fa-fw fa-file-alt", - "variable": "AllskyWebsiteVersion", - "value": "", - "style": "" - }, - { - "comment": "Add popout items above.", - "display": false - } - ] - }, - "ConfigVersion" : "1" + "comment": "See the 'Settings -> Allsky Website' documentation page for a description of these settings.", + "config": { + "comment": "These settings impact what appears in the popout and the constellation overlay", + "imageName": "/current/tmp/image.jpg", + "location": "XX_NEED_TO_UPDATE_XX", + "latitude": "XX_NEED_TO_UPDATE_XX", + "longitude": "XX_NEED_TO_UPDATE_XX", + "camera": "XX_NEED_TO_UPDATE_XX", + "lens": "XX_NEED_TO_UPDATE_XX", + "computer": "XX_NEED_TO_UPDATE_XX", + "owner": "XX_NEED_TO_UPDATE_XX", + "auroraForecast": false, + "auroraMap": "XX_NEED_TO_UPDATE_XX", + "intervalSeconds": 5, + "showOverlayAtStartup": false, + "overlayWidth": 875, + "overlayHeight": 875, + "overlayOffsetLeft": 0, + "overlayOffsetTop": 0, + "az": 0, + "imageWidth": 900, + "opacity": 0.5, + "objectsComment": "If you want the Messier objects on the overlay remove the 'XXX_' below.", + "XXX_objects": "virtualsky/messier.json", + "meridian": false, + "ecliptic": false, + "fontsize": "14px", + "cardinalpoints": true, + "cardinalpoints_fontsize": "18px", + "showstarlabels": true, + "projection": "fisheye", + "constellations": true, + "constellationwidth": 0.75, + "constellationlabels": false, + "constellationboundaries": false, + "constellationboundarieswidth": 0.75, + "gridlines_eq": false, + "gridlineswidth": 0.75, + "showgalaxy": false, + "galaxywidth": 0.75, + "mouse": false, + "keyboard": true, + "showdate": false, + "showposition": false, + "sky_gradient": false, + "gradient": false, + "transparent": true, + "lang": "en", + "colours": { + "comment": "See the Wiki for how to change colors on the constellation overlay.", + "normal": { + "XXX_cardinal": "rgba(0,255,0,1)" + }, + "negative": { + "XXX_cardinal": "rgba(255,255,255,1)" + } + }, + "live": true, + "id": "starmap", + "AllskyVersion": "XX_ALLSKY_VERSION_XX" + }, + "homePage": { + "comment": "These settings impact the look and feel of the Website home page.", + "title": "XX_NEED_TO_UPDATE_XX", + "og_description": "XX_NEED_TO_UPDATE_XX", + "backgroundImage": { + "url": "", + "style": "" + }, + "loadingImage": "loading.jpg", + "imageBorder": false, + "includeGoogleAnalytics": false, + "includeLinkToMakeOwn": true, + "personalLink": { + "prelink": "", + "message": "", + "url": "", + "title": "", + "style": "" + }, + "og_type": "website", + "og_url": "https://github.com/AllskyTeam/allsky/", + "og_image": "image.jpg", + "favicon": "allsky-favicon.png", + "thumbnailsizex": 100, + "thumbnailsizey": 75, + "thumbnailsortorder": "ascending", + "leftSidebar": [ + { + "comment": "Once you have modified the settings so the overlay fits, set 'display' to true.", + "display": false, + "title": "Show constellation overlay", + "icon": "fa fa-2x fa-fw allsky-constellation", + "other": "id='overlayBtn' ng-click='toggleOverlay()' ng-class=\"{'active': showOverlay}\"", + "style": "" + }, + { + "display": true, + "url": "videos/", + "title": "Archived Timelapes", + "icon": "fa fa-2x fa-fw fa-play-circle", + "style": "" + }, + { + "display": false, + "url": "/current/tmp/mini-timelapse.mp4", + "title": "Mini-timelapse", + "icon": "fa fa-2x fa-fw icon-mini-timelapse", + "style": "" + }, + { + "display": true, + "url": "keograms/", + "title": "Archived Keograms", + "icon": "fa fa-2x fa-fw fa-barcode", + "style": "" + }, + { + "display": true, + "url": "startrails/", + "title": "Archived Startrails", + "icon": "fa fa-2x fa-fw fa-star", + "style": "" + }, + { + "display": true, + "variable": "imageName", + "title": "Full-sized image", + "icon": "fa fa-2x fa-fw fa-expand-arrows-alt", + "style": "" + }, + { + "display": true, + "title": "Display information about the camera and other settings", + "icon": "fa fa-2x fa-fw fa-info-circle", + "other": "ng-click='toggleInfo()' ng-class=\"{'active': showInfo}\"", + "style": "" + }, + { + "display": false, + "comment": "Add leftSidebar items above." + } + ], + "leftSidebarStyle": "", + "popoutIcons": [ + { + "display": true, + "label": "Location", + "icon": "fa fa-fw fa-map-marker-alt", + "variable": "location", + "value": "", + "style": "" + }, + { + "display": true, + "label": "Latitude", + "icon": "fa fa-fw fa-map-marker", + "variable": "s_latitude", + "value": "", + "style": "" + }, + { + "display": true, + "label": "Longitude", + "icon": "fa fa-fw fa-map-marker", + "variable": "s_longitude", + "value": "", + "style": "" + }, + { + "display": true, + "label": "Camera", + "icon": "fa fa-fw fa-camera-retro", + "variable": "camera", + "value": "", + "style": "" + }, + { + "display": true, + "label": "Lens", + "icon": "fa fa-fw fa-dot-circle", + "variable": "lens", + "value": "", + "style": "" + }, + { + "display": true, + "label": "Computer", + "icon": "fa fa-fw fa-microchip", + "variable": "computer", + "value": "", + "style": "" + }, + { + "display": true, + "label": "Owner", + "icon": "fa fa-fw fa-user", + "variable": "owner", + "value": "", + "style": "" + }, + { + "display": false, + "label": "Allsky Settings", + "icon": "fa fa-fw fa-cogs", + "variable": "", + "value": "Click to view", + "style": "" + }, + { + "display": true, + "label": "Allsky Version", + "icon": "fa fa-fw fa-file-alt", + "variable": "AllskyVersion", + "value": "", + "style": "" + }, + { + "comment": "Add popout items above." + } + ] + }, + "ConfigVersion": "2" } diff --git a/config_repo/env.json.repo b/config_repo/env.json.repo new file mode 100644 index 000000000..45185f703 --- /dev/null +++ b/config_repo/env.json.repo @@ -0,0 +1,25 @@ +{ + "WARNING" : "Do NOT edit this file manually. Use the WebUI's 'Allsky Settings' page.", + "REMOTEWEBSITE_HOST" : "", + "REMOTEWEBSITE_PORT" : "", + "REMOTEWEBSITE_USER" : "", + "REMOTEWEBSITE_PASSWORD" : "", + "REMOTEWEBSITE_LFTP_COMMANDS" : "", + "REMOTEWEBSITE_SSH_KEY_FILE" : "", + "REMOTEWEBSITE_AWS_CLI_DIR" : "", + "REMOTEWEBSITE_S3_BUCKET" : "allskybucket", + "REMOTEWEBSITE_S3_ACL" : "private", + "REMOTEWEBSITE_GCS_BUCKET" : "allskybucket", + "REMOTEWEBSITE_GCS_ACL" : "private", + "REMOTESERVER_HOST" : "", + "REMOTESERVER_PORT" : "", + "REMOTESERVER_USER" : "", + "REMOTESERVER_PASSWORD" : "", + "REMOTESERVER_LFTP_COMMANDS" : "", + "REMOTESERVER_SSH_KEY_FILE" : "", + "REMOTESERVER_AWS_CLI_DIR" : "", + "REMOTESERVER_S3_BUCKET" : "allskybucket", + "REMOTESERVER_S3_ACL" : "private", + "REMOTESERVER_GCS_BUCKET" : "allskybucket", + "REMOTESERVER_GCS_ACL" : "private" +} diff --git a/config_repo/ftp-settings.sh.repo b/config_repo/ftp-settings.sh.repo deleted file mode 100644 index 89c91ebe3..000000000 --- a/config_repo/ftp-settings.sh.repo +++ /dev/null @@ -1,80 +0,0 @@ -#!/bin/bash - -# X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*XX*X*X*X*X*X*X - -# For details on these settings, click on the "Allsky Documentation" link in the WebUI, -# then click on the "Settings -> Allsky" link, -# then, in the "Editor WebUI Page" section, open the "ftp-settings.sh" sub-section. - -# X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*X*XX*X*X*X*X*X*X - - - - # How will files be uploaded? -PROTOCOL="" - - # The directory where the current image should be copied to. -IMAGE_DIR="" -WEB_IMAGE_DIR="" - - # The directory where the timelapse video should be uploaded to. -VIDEOS_DIR="" -VIDEOS_DESTINATION_NAME="" -WEB_VIDEOS_DIR="" - - # The directory where the keogram image should be copied to. -KEOGRAM_DIR="" -KEOGRAM_DESTINATION_NAME="" -WEB_KEOGRAM_DIR="" - - # The directory where the startrails image should be copied to. -STARTRAILS_DIR="" -STARTRAILS_DESTINATION_NAME="" -WEB_STARTRAILS_DIR="" - - -############### ftp, ftps, sftp, and scp PROTOCOLS only: - # Name of the remote server. -REMOTE_HOST="" - - # Optionally enter the port required by your server. -REMOTE_PORT="" - - -############### ftp, ftps, and sftp PROTOCOLS only. REMOTE_USER is also used by the scp PROTOCOL: - # The username of the login on the remote server. -REMOTE_USER="" - - # The password of the login on your FTP server. Does not apply to PROTOCOL=scp. -REMOTE_PASSWORD="" - - # If you need special commands executed when connecting to the FTP server enter them here. -LFTP_COMMANDS="" - - -############### scp PROTOCOL only: - # The path to the SSH key. -SSH_KEY_FILE="" - - -############### S3 PROTOCOL only: - # AWS CLI directory where the AWS CLI tools are installed. -AWS_CLI_DIR="/home/pi/.local/bin" - - # Name of S3 Bucket where the files will be uploaded. -S3_BUCKET="allskybucket" - - # S3 Access Control List (ACL). -S3_ACL="private" - - -############### GCS PROTOCOL only: - # Name of the GCS bucket where the files are uploaded. -GCS_BUCKET="allskybucket" - - # GCS Access Control List (ACL). -GCS_ACL="private" - - -#### DO NOT CHANGE THE NEXT LINE -FTP_SH_VERSION=1 diff --git a/config_repo/lighttpd.conf.repo b/config_repo/lighttpd.conf.repo index 1891d27ac..79947638e 100644 --- a/config_repo/lighttpd.conf.repo +++ b/config_repo/lighttpd.conf.repo @@ -4,15 +4,14 @@ server.document-root = "XX_ALLSKY_WEBUI_XX" server.upload-dirs = ( "/var/cache/lighttpd/uploads" ) server.errorlog = "/var/log/lighttpd/error.log" server.pid-file = "/var/run/lighttpd.pid" -#server.username = "www-data" -#server.groupname = "www-data" +server.username = "XX_WEBSERVER_OWNER_XX" +server.groupname = "XX_WEBSERVER_GROUP_XX" server.port = 80 server.tag = "Allsky" dir-listing.activate = "enable" setenv.add-response-header += ( "X-Content-Type-Options" => "nosniff" ) - setenv.add-environment = ( "LANG" => env.LANG, "ALLSKY_HOME" => "XX_ALLSKY_HOME_XX/" @@ -23,32 +22,30 @@ alias.url += ("/images/" => "XX_ALLSKY_IMAGES_XX/") alias.url += ("/config/" => "XX_ALLSKY_CONFIG_XX/") alias.url += ("/website/" => "XX_ALLSKY_WEBSITE_XX/") alias.url += ("/documentation" => "XX_ALLSKY_DOCUMENTATION_XX/") +alias.url += ("/overlayTemplates" => "XX_MY_OVERLAY_TEMPLATES_XX/") alias.url += ("/overlay" => "XX_ALLSKY_OVERLAY_XX/") $HTTP["url"] =~ "^/cgi-bin/" { - cgi.assign = ( ".py" => "/usr/bin/python3" ) + cgi.assign = ( ".py" => "/usr/bin/python3" ) alias.url += ( "/cgi-bin/" => "XX_ALLSKY_HOME_XX/html/cgi-bin/" ) +} else $HTTP["url"] =~ "^/overlay/config/" { + url.access-deny = ("") +} else $HTTP["url"] =~ "allsky/videos|allsky/startrails|allsky/keograms" { + # Output web pages with thumbnails as each thumbnail is read (or created) so the thumbnails appear quickly, + # rather than not outputing anything until the last thumbnail is created, which can be tens of seconds. + server.stream-response-body = 1 } - -$HTTP["url"] =~ "^/current|.json$" { +$HTTP["url"] =~ "^/current|.json$|videos/$|startrails/$|keograms/$" { # Don't cache the "current" image since it is constantly changing. # Also don't cache .json files since they are configuration files that can change. + # And don't cache the videos, startrails, and keograms files since they will normally + # change every day by adding another thumbnail. # This eliminates the need to add timestamps to URLs. setenv.add-response-header += ( "Cache-Control" => "no-cache, proxy-revalidate" ) } else { - $HTTP["url"] =~ "thumbnails|allsky/videos|allsky/startrails|allsky/keograms" { - # Output web pages with thumbnails as each thumbnail is read (or created) so the thumbnails appear quickly, - # rather than not outputing anything until the last thumbnail is created, which can be tens of seconds. - server.stream-response-body = 1 - } - # Cache pages - setenv.add-response-header += ( "Cache-Control" => "max-age=31536000, immutable" ) -} - -$HTTP["url"] =~ "^/overlay/config/" { - url.access-deny = ("") + setenv.add-response-header += ( "Cache-Control" => "max-age=31536001, immutable" ) } # strict parsing and normalization of URL for consistency and security diff --git a/config_repo/options.json.md b/config_repo/options.json.md new file mode 100644 index 000000000..314335bbc --- /dev/null +++ b/config_repo/options.json.md @@ -0,0 +1,139 @@ +This file is for Allsky developers and describes the fields in an entry in the options.json file. +Most entries are settings but a few, e.g., "header" tell the WebUI how to display the page. + +The options.json file tells the WebUI what settings the user can change and how to display them. Many settings and/or their minimum, maximum, and default values are camera-dependent. For example, only cameras with a cooler should display the "Cooling" setting for the user to change. + +The options.json.repo file has ALL possible settings and is used to create the options.json file based on the camera's capabilities. As described below, some values are placeholders and are replaced when the options file is created. + +Each field in the options file is listed below as well as it's purpose, Type, Default, and Notes. +Unless otherwise specified, the Default value is what's used if the field isn't specified for a setting. +If a Note contains the phrases __If specified__ or __Optional__ it means the field doesn't need to be specified. +Other than the first few fields, fields can be in any order but are usually in the same order to make viewing the file easier. + +* __name__ + * Name of the setting that appears in the settings file. + * Type: string + * Default: none - must be present + * Notes: __Must be the 1st field__. This is the json name and users generally won't see it. Must be all lowercase and contain no spaces. Names that start with "day" or "night" may have the same valid values (e.g., "daybin" and "nightbin") and the part of the name that comes after "day" or "night" e.g., "bin", may be used to lookup the common values. +* __display__ + * Determines whether or not the field is displayed in the WebUI. + * Type: boolean + * Default: true + * Notes: If specified, __must be the 2nd field__ (to keep the WebUI from parsing the rest of the setting if __false__). Can be __true__, __false__, or ___display__ which means the value depends on the camera and is determined when the options file is created. +* __settingsonly__ + * Determines if this setting ONLY appears in the settings file and not the WebUI. Used for "internal" settings. + * Type: boolean + * Default: false + * Notes: If specified, __must be 3rd field__. Overrides "display : true". +* __minimum__ + * The setting's minimum value, if any. + * Type: same as the setting's __type__. + * Default: none + * Notes: Optional. Values that contain a "_" e.g., "_min" or "day_min" are placeholders. +* __maximum__ + * The setting's maximum value, if any. + * Type: same as the setting's __type__. + * Default: none + * Notes: Optional. Values that contain a "_" e.g., "_min" or "night_max" are placeholders. +* __default__ + * The setting's default value, if any. + * Type: same as the setting's __type__. + * Default: none + * Notes: Optional. Values that contain a "_" e.g., "_default" or "night_default" are placeholders. +* __description__ + * A description of the settings. + * Type: text + * Default: none + * Notes: Any setting that's displayed in the WebUI must have a description. Can contain html since it's displayed in the WebUI. The text displayed after html substitutions should be at most several lines so it doesn't take too much space in the WebUI. If there's more to say, consider having a "click here for more info" link. +* __label__ + * The human-readable name of the setting displayed in the WebUI. + * Type: text + * Default: none + * Notes: Any setting that's displayed in the WebUI must have a label. Typically 1 - 3 words. +* __label_prefix__ + * A prefix for programs to prepend to the label. + * Type: text + * Default: none + * Notes: Optional. To improve readability, some labels are short, e.g., "Generate". The prefix can be used by programs to clarify the label, e.g., "Timelapse Generate". Typically 1 - 3 words. +* __type__ + * The setting's type. + * Type: text + * Default: none - MUST be specified. + * Notes: Valid values: + * _boolean_ - a setting whose value in the settings file is either "true" or "false". + * _color_ - a text value representing a color, e.g., "#fff". The WebUI should present a color wheel for the user to set the value so they don't see the actual value (which may not make sense to them). + * _float_ - a number with a decimal point (period or comma, depending on locale) + * _header_ - defines a section header in the WebUI which in turn contains one or (usually) more settings. + * _header-column_ - defines the column names when multiple settings are displayed in the WebUI on one line. + * _header-sub_ - a sub-header in the WebUI. There can be 0 or more sub-headings under a header. + * _header-tab_ - defines a new tab in the WebUI. + * _integer_ - a number without a decimal point. + * _password_ - same as "text" but displayed with "***" in the WebUI. + * _percent_ - a "float" that acts as a percent. The WebUI should display "%" after the number but the number must be stored in the settings file without the "%". + * _select*_ - a drop-down selection. Must have an __options__ field that defines the choices. The actual type of the setting is what comes after the underscore, e.g., __select_integer__. + * _text_ - a single, short line of text - usually a single word. + * _widetext_ - a longer line of text. Should fit in a single line in the WebUI to avoid having to scroll right and left. +* __usage__ + * Lists what this setting is used for. + * Type: text + * Default: none + * Notes: Optional - the only current value is "capture" which means the setting is used by the capture programs. If "capture" then the __action__ field MUST be present. +* __readonly__ + * A setting that's displayed in the WebUI but isn't editable. Is displayed as "text". + * Type: boolean + * Default: false + * Notes: Optional. Not used often. +* __carryforward__ + * When a new camera is detected and there's a settings file for another camera, this field determine whether or not the setting's value in the other file should be used in the new settings file. This minimizes the number of settings the user needs to change. + * Type: boolean + * Default: false + * Notes: Optional. Typically used for settings that aren't dependent on the camera type and model, e.g., latitude. This is somewhat subjective. +* __options__ + * Sets the options for drop-down lists. + * Type: json array + * Default: none - required if setting __type__ is _select*_. + * Notes: Settings that are camera-dependent like "bin" will usually have a single placeholder entry like __[ "bin_values" ]__. Hard-coded options must have one or more elements, each with a field called __value__ its value as well as a field called __label__ and its value, e.g., __{"value" : 1, "label" : "module"}__. The __value__ field's value is what's stored in the settings file. The type of the value stored in the settings file is either an integer/float or text depending on the value. The __label__ field's value is what's displayed in the drop-down as text. +* __checkchanges__ + * Should the setting's new value be verified after it's changed in the WebUI? + * Type: boolean + * Default: false + * Notes: Optional. The setting's new value is passed to the "makeChanges.sh" to validate it and/or perform "behind the scenes" actions like sending information to a remote Website. This is why users should NEVER edit the settings file directly. +* __optional__ + * Is this setting optional, i.e., can it be left blank. + * Type: boolean + * Default: false + * Notes: Optional. Normally does not apply to boolean settings. +* __source__ + * What is the source of this setting, i.e., what .json file is it stored in? + * Type: text + * Default: none, which means settings.json + * Notes: If specified, it's either a full pathname to the .json file or a variable that's replaced by the actual name of the file by the WebUI. Currently the valid variables are __${HOME}__, __${ALLSKY_ENV}__, and __${ALLSKY_HOME}__ although only __${ALLSKY_ENV}__ is currently used. +* __action__ + * Determines what should happen after the setting is changed. + * Type: text + * Default: none + * Notes: Optional. Current choices determine what the capture program should do: + * _reload_ - the program re-reads the file containing the command-line arguments (which is created partially using the settings file). + * _restart_ - the program restarts - other than for camera changes, this isn't needed very often. + * _stop_ - the program stops and requires the user to manually start it. For example, when enabling dark frame capture, this allows the user to cover the lens then start Allsky. +* __booldependson__ + * Lists which boolean setting(s) that when "true" (a.k.a., "on") will cause this setting to be displayed. + * Type: list of one or more setting __name__'s. + * Default: none + * Notes: __This field will be replaced when the "new WebUI" is available.__ It currently only exists to indicate which settings have a dependency and what those dependencies are. +* __booldependsoff__ + * Lists which boolean setting(s) that when "false" (a.k.a., "off") will cause this setting to be displayed. + * Type: list of one or more setting __name__'s. + * Default: none + * Notes: __This field will be replaced when the "new WebUI" is available.__ It currently only exists to indicate which settings have a dependency and what those dependencies are. +* __popup-yesno__ + * Text to display in a popup when the setting's value changes to the value specified in __popup-yesno-value__. + * Type: text + * Default: none + * Notes: __This field will be replaced when the "new WebUI" is available.__ It currently only exists to indicate which settings should have a popup. +* __popup-yesno-value__ + * A settings new value that causes a popup to be shown. + * Type: number - 0 (false) or 1 (true) + * Default: none + * Notes: __This field will be replaced when the "new WebUI" is available.__ It currently only exists to indicate what value should produce a popup. + * TODO: Need to specify the action to take based on the user's answer to the __popup-yesno__ text. diff --git a/config_repo/options.json.repo b/config_repo/options.json.repo index d0ce19480..93fecfce4 100644 --- a/config_repo/options.json.repo +++ b/config_repo/options.json.repo @@ -1,47 +1,67 @@ [ { -"name" : "daytimeSettingsHeader", -"description" : "Daytime settings", -"type" : "header", -"display" : 1 +"name" : "daynighttab========================================", +"display" : false, +"label" : "Day/Night Settings", +"type" : "header-tab" }, { -"name" : "takeDaytimeImages", -"default" : 1, -"description" : "Activate to take daytime images.", -"label" : "Daytime Capture", +"name" : "daytimesettingsheader", +"label" : "Daytime Settings", +"type" : "header" +}, +{ +"name" : "daynightcolumns", +"display" : false, +"label" : "Setting, Day Value, Description", +"candrop" : "Description", +"type" : "header-column" +}, +{ +"name" : "takedaytimeimages", +"default" : true, +"description" : "Enable to take daytime images.", +"label" : "Capture", +"label_prefix" : "Daytime", "type" : "boolean", -"checkchanges" : 1, -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"checkchanges" : true, +"action" : "reload" }, { -"name" : "saveDaytimeImages", -"default" : 0, -"description" : "Activate to save daytime images.
Only applies if Daytime Capture is enabled.", -"label" : "Daytime Save", +"name" : "savedaytimeimages", +"default" : false, +"description" : "Enable to save daytime images.", +"label" : "Save", +"label_prefix" : "Daytime", "type" : "boolean", -"generic" : 1, -"display" : 1 +"carryforward" : true, +"booldependson" : "takedaytimeimages" }, { "name" : "dayautoexposure", -"default" : 1, -"description" : "Activate to enable daytime auto-exposure.", +"default" : true, +"description" : "Enable to use daytime auto-exposure.", "label" : "Auto-Exposure", +"label_prefix" : "Daytime", "type" : "boolean", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"booldependson" : "takedaytimeimages", +"action" : "reload" }, { "name" : "daymaxautoexposure", "minimum" : "_min", "maximum" : "_max", "default" : "_default", -"description" : "Maximum daytime Auto-Exposure time in milliseconds (1000ms = 1 sec).
Only applies if daytime Auto-Exposure is enabled.", +"description" : "Maximum daytime Auto-Exposure time in milliseconds (1000ms = 1 sec).", "label" : "Max Auto-Exposure", +"label_prefix" : "Daytime", "type" : "float", -"display" : 1 +"usage" : "capture", +"booldependson" : "takedaytimeimages AND daymaxautoexposure", +"action" : "reload" }, { "name" : "dayexposure", @@ -50,8 +70,11 @@ "default" : "day_default", "description" : "Manual exposure time in milliseconds (1000ms = 1 sec). Can be a fraction.
If using daytime Auto-Exposure, this number is used as a starting point.", "label" : "Manual Exposure", +"label_prefix" : "Daytime", "type" : "float", -"display" : 1 +"usage" : "capture", +"booldependson" : "takedaytimeimages", +"action" : "reload" }, { "name" : "daymean", @@ -60,28 +83,24 @@ "default" : "day_default", "description" : "The target mean brightness level when Auto-Exposure is on. 1.0 is pure white.
Best used when Auto-Exposure and Auto-Gain are on.
0 disables this auto-exposure mode.", "label" : "Mean Target", +"label_prefix" : "Daytime", "type" : "float", -"display" : 1 +"usage" : "capture", +"booldependson" : "takedaytimeimages", +"action" : "reload" }, { "name" : "daymeanthreshold", "minimum" : "day_min", "maximum" : "day_max", "default" : "day_default", -"description" : "When using Mean Target this specifies how close to the target the brightness should be.", +"description" : "When using Mean Target this specifies how close to the target the brightness should be.", "label" : "Mean Threshold", +"label_prefix" : "Daytime", "type" : "float", -"display" : 1 -}, -{ -"name" : "daybrightness", -"minimum" : "_min", -"maximum" : "_max", -"default" : "_default", -"description" : "Deprecated. Use Mean Target instead.
Changes the amount of light in the image. Higher numbers are brighter.", -"label" : "Brightness", -"type" : "float", -"display" : "_display" +"usage" : "capture", +"booldependson" : "takedaytimeimages AND daymean", +"action" : "reload" }, { "name" : "daydelay", @@ -90,18 +109,23 @@ "default" : 30000, "description" : "Delay between daytime images in milliseconds (1000ms = 1 sec).", "label" : "Delay", +"label_prefix" : "Daytime", "type" : "integer", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"booldependson" : "takedaytimeimages", +"action" : "reload" }, { "name" : "dayautogain", -"default" : 0, -"description" : "Activate to enable dayime Auto-Gain.", +"default" : "_default", +"description" : "Enable to use dayime Auto-Gain.", "label" : "Auto-Gain", +"label_prefix" : "Daytime", "type" : "boolean", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"booldependson" : "takedaytimeimages", +"action" : "reload" }, { "name" : "daymaxautogain", @@ -110,125 +134,205 @@ "default" : "_default", "description" : "Maximum gain when using Auto-Gain.", "label" : "Max Auto-Gain", +"label_prefix" : "Daytime", "type" : "float", -"display" : 1 +"usage" : "capture", +"booldependson" : "takedaytimeimages AND dayautogain", +"action" : "reload" }, { "name" : "daygain", "minimum" : "_min", "maximum" : "_max", "default" : "day_default", -"description" : "Manually sets the light sensitivity of the camera.
A high number returns a brighter image but more noise too. Can normally leave at the minimum value for daytime.", +"description" : "Manually sets the light sensitivity of the camera.
A high number returns a brighter image but more noise. Can normally leave at the minimum value for daytime.", "label" : "Gain", +"label_prefix" : "Daytime", "type" : "float", -"display" : 1 +"usage" : "capture", +"booldependson" : "takedaytimeimages", +"action" : "reload" +}, +{ +"name" : "imagestretchamountdaytime", +"minimum" : 0, +"maximum" : 100, +"default" : 0, +"description" : "Amount to stretch daytime images. 0 means don't stretch.", +"label" : "Stretch Amount", +"label_prefix" : "Daytime", +"type" : "integer", +"booldependson" : "takedaytimeimages" +}, +{ +"name" : "imagestretchmidpointdaytime", +"minimum" : 1, +"maximum" : 100, +"default" : 10, +"description" : "Mid point of image stretch.
Lower numbers brighten darker parts of image; higher numbers brighten lighter parts.
Only applies if Stretch Amount is greater than 0.", +"label" : "Stretch Mid Point", +"label_prefix" : "Daytime", +"type" : "percent", +"booldependson" : "takedaytimeimages" }, { "name" : "daybin", "default" : 1, "description" : "Binning level. 1x1 = OFF.", "label" : "Binning", -"type" : "select", +"label_prefix" : "Daytime", +"type" : "select_integer", +"usage" : "capture", "options" : [ "bin_values" ], -"generic" : 1, -"display" : 1 +"booldependson" : "takedaytimeimages", +"action" : "reload" }, { "name" : "dayawb", -"default" : 0, -"description" : "Sets Auto White Balance.", +"display" : "_display", +"default" : false, +"description" : "Sets Auto White Balance (camera adjusts color).", "label" : "Auto White Balance", +"label_prefix" : "Daytime", "type" : "boolean", -"generic" : 1, -"display" : "_display" +"usage" : "capture", +"booldependson" : "takedaytimeimages", +"action" : "reload" }, { "name" : "daywbr", +"display" : "_display", "minimum" : "_min", "maximum" : "_max", "default" : "_default", "description" : "Red component for the white balance.
When using Auto White Balance, this number is a starting point.", "label" : "Red Balance", +"label_prefix" : "Daytime", "type" : "float", -"display" : "_display" +"usage" : "capture", +"booldependson" : "takedaytimeimages AND dayawb", +"action" : "reload" }, { "name" : "daywbb", +"display" : "_display", "minimum" : "_min", "maximum" : "_max", "default" : "_default", "description" : "Blue component for the white balance.
When using Auto White Balance, this number is a starting point.", "label" : "Blue Balance", +"label_prefix" : "Daytime", "type" : "float", -"display" : "_display" +"usage" : "capture", +"booldependson" : "takedaytimeimages AND dayawb", +"action" : "reload" }, { "name" : "dayskipframes", "minimum" : 0, "maximum" : "none", "default" : 5, -"description" : "When starting Allsky during the day, skip up to this many frames while the software gets to the correct exposure.
Only applies if daytime Auto-Exposure is on.", +"description" : "When starting Allsky during the day, skip up to this many frames while the software gets to the correct exposure.", "label" : "Frames To Skip", +"label_prefix" : "Daytime", "type" : "integer", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"booldependson" : "takedaytimeimages AND dayautoexposure", +"action" : "reload" }, { -"name" : "dayEnableCooler", -"default" : 0, -"description" : "Activate to use cooling (works only on cooled cameras).", +"name" : "dayenablecooler", +"display" : "_display", +"default" : false, +"description" : "Enable to use cooling (on cooled cameras only).", "label" : "Cooling", +"label_prefix" : "Daytime", "type" : "boolean", -"generic" : 1, -"display" : "_display" +"usage" : "capture", +"carryforward" : true, +"booldependson" : "takedaytimeimages", +"action" : "reload" }, { -"name" : "dayTargetTemp", +"name" : "daytargettemp", +"display" : "_display", "minimum" : "_min", "maximum" : "_max", "default" : "_default", "description" : "Sensor's target temperature when cooler is enabled (degrees Celsius).", "label" : "Target Temp.", +"label_prefix" : "Daytime", "type" : "integer", -"generic" : 1, -"display" : "_display" +"usage" : "capture", +"booldependson" : "takedaytimeimages AND dayenablecooler", +"action" : "reload" }, { -"name" : "dayTuningFile", +"name" : "daytuningfile", +"display" : "_display", "default" : "", -"description" : "Name of the day camera tuning file to use. Omit this option to preserve the default behavior of libcamera.
Please read this documentation or this document if using a Raspberry camera with libcamera. For other Raspberry cameras read the camera manual for more information.", +"description" : "Name of the optional day camera tuning file to use.
See the documentation for a description of this setting.", "label" : "Tuning File", +"label_prefix" : "Daytime", "type" : "widetext", -"checkchanges" : 1, -"optional" : 1, -"display" : "_display" +"usage" : "capture", +"checkchanges" : true, +"optional" : true, +"booldependson" : "takedaytimeimages", +"action" : "restart" +}, + +{ +"name" : "nighttimesettingsheader", +"label" : "Nighttime Settings", +"type" : "header" +}, +{ +"name" : "takenighttimeimages", +"default" : true, +"description" : "Enable to take nighttime images.", +"label" : "Capture", +"label_prefix" : "Nighttime", +"type" : "boolean", +"usage" : "capture", +"carryforward" : true, +"checkchanges" : true, +"action" : "reload" }, { -"name" : "nighttimeSettingsHeader", -"description" : "Nighttime settings", -"type" : "header", -"display" : 1 +"name" : "savenighttimeimages", +"default" : true, +"description" : "Enable to save nighttime images.", +"label" : "Save", +"label_prefix" : "Nighttime", +"type" : "boolean", +"carryforward" : true, +"booldependson" : "takenighttimeimages" }, { "name" : "nightautoexposure", -"default" : 1, -"description" : "Activate to enable nighttime auto-exposure.", +"default" : true, +"description" : "Enable to use nighttime auto-exposure.", "label" : "Auto-Exposure", +"label_prefix" : "Nighttime", "type" : "boolean", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"action" : "reload" }, { "name" : "nightmaxautoexposure", "minimum" : "_min", "maximum" : "_max", "default" : "_default", -"description" : "Maximum nighttime Auto-Exposure time in milliseconds (1000ms = 1 sec).
Only applies if nighttime Auto-Exposure is enabled.", +"description" : "Maximum nighttime Auto-Exposure time in milliseconds (1000ms = 1 sec).", "label" : "Max Auto-Exposure", +"label_prefix" : "Nighttime", "type" : "float", -"display" : 1 +"usage" : "capture", +"booldependson" : "nightautoexposure", +"action" : "reload" }, { "name" : "nightexposure", @@ -237,8 +341,10 @@ "default" : "night_default", "description" : "Manual exposure time in milliseconds (1000ms = 1 sec). Can be a fraction.
If using nighttime Auto-Exposure, this number is used as a starting point.", "label" : "Manual Exposure", +"label_prefix" : "Nighttime", "type" : "float", -"display" : 1 +"usage" : "capture", +"action" : "reload" }, { "name" : "nightmean", @@ -247,28 +353,23 @@ "default" : "night_default", "description" : "The target mean brightness level when Auto-Exposure is on. 1.0 is pure white.
Best used when Auto-Exposure and Auto-Gain are on.
0 disables this auto-exposure mode.", "label" : "Mean Target", +"label_prefix" : "Nighttime", "type" : "float", -"display" : 1 +"usage" : "capture", +"action" : "reload" }, { "name" : "nightmeanthreshold", "minimum" : "night_min", "maximum" : "night_max", "default" : "day_default", -"description" : "When using Mean Target this specifies how close to the target the brightness should be.", +"description" : "When using Mean Target this specifies how close to the target the brightness should be.", "label" : "Mean Threshold", +"label_prefix" : "Nighttime", "type" : "float", -"display" : 1 -}, -{ -"name" : "nightbrightness", -"minimum" : "_min", -"maximum" : "_max", -"default" : "_default", -"description" : "Deprecated. Use Mean Target instead.
Changes the amount of light in the image. Higher numbers are brighter.", -"label" : "Brightness", -"type" : "float", -"display" : "_display" +"usage" : "capture", +"booldependson" : "nightmean", +"action" : "reload" }, { "name" : "nightdelay", @@ -277,18 +378,21 @@ "default" : 30000, "description" : "Delay between nighttime images in milliseconds (1000ms = 1 sec).", "label" : "Delay", +"label_prefix" : "Nighttime", "type" : "integer", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"action" : "reload" }, { "name" : "nightautogain", -"default" : 0, -"description" : "Activate to enable nighttime Auto-Gain.", +"default" : "_default", +"description" : "Enable to use nighttime Auto-Gain.", "label" : "Auto-Gain", +"label_prefix" : "Nighttime", "type" : "boolean", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"action" : "reload" }, { "name" : "nightmaxautogain", @@ -297,106 +401,173 @@ "default" : "_default", "description" : "Maximum gain when using Auto-Gain.", "label" : "Max Auto-Gain", +"label_prefix" : "Nighttime", "type" : "float", -"display" : 1 +"usage" : "capture", +"booldependson" : "nightautogain", +"action" : "reload" }, { "name" : "nightgain", "minimum" : "_min", "maximum" : "_max", "default" : "night_default", -"description" : "Manually sets the light sensitivity of the camera.
A high number returns a brighter image but more noise too.", +"description" : "Manually sets the light sensitivity of the camera.
A high number returns a brighter image but more noise.", "label" : "Gain", +"label_prefix" : "Nighttime", "type" : "float", -"display" : 1 +"usage" : "capture", +"action" : "reload" +}, +{ +"name" : "imagestretchamountnighttime", +"minimum" : 0, +"maximum" : 100, +"default" : 0, +"description" : "Amount to stretch nighttime images. 0 means don't stretch.", +"label" : "Stretch Amount", +"label_prefix" : "Nighttime", +"type" : "integer", +"booldependson" : "takenighttimeimages" +}, +{ +"name" : "imagestretchmidpointnighttime", +"minimum" : 1, +"maximum" : 100, +"default" : 10, +"description" : "Mid point of image stretch.
Lower numbers brighten darker parts of image; higher numbers brighten lighter parts.
Only applies if Stretch Amount is greater than 0.", +"label" : "Stretch mid point", +"label_prefix" : "Nighttime", +"type" : "percent", +"booldependson" : "takenighttimeimages" }, { "name" : "nightbin", "default" : 1, "description" : "Binning level. 1x1 = OFF.", "label" : "Binning", -"type" : "select", +"label_prefix" : "Nighttime", +"type" : "select_integer", +"usage" : "capture", "options" : [ "bin_values" ], -"generic" : 1, -"display" : 1 +"action" : "reload" }, { "name" : "nightawb", -"default" : 0, -"description" : "Activate to enable Auto White Balance (camera adjusts color).", +"display" : "_display", +"default" : false, +"description" : "Enable to use Auto White Balance (camera adjusts color).
NOTE: enabling this can significantly increase the time needed to capture a nighttime image.", "label" : "Auto White Balance", +"label_prefix" : "Nighttime", "type" : "boolean", -"generic" : 1, -"display" : "_display" +"usage" : "capture", +"action" : "reload" }, { "name" : "nightwbr", +"display" : "_display", "minimum" : "_min", "maximum" : "_max", "default" : "_default", "description" : "Red component for the white balance.
When using Auto White Balance, this number is a starting point.", "label" : "Red Balance", +"label_prefix" : "Nighttime", "type" : "float", -"display" : "_display" +"usage" : "capture", +"booldependsoff" : "nightawb", +"action" : "reload" }, { "name" : "nightwbb", +"display" : "_display", "minimum" : "_min", "maximum" : "_max", "default" : "_default", "description" : "Blue component for the white balance.
When using Auto White Balance, this number is a starting point.", "label" : "Blue Balance", +"label_prefix" : "Nighttime", "type" : "float", -"display" : "_display" +"usage" : "capture", +"booldependsoff" : "nightawb", +"action" : "reload" }, { "name" : "nightskipframes", "minimum" : 0, "maximum" : "none", "default" : 1, -"description" : "When starting Allsky at night, skip up to this many frames while the software gets to the correct exposure.
Only applies if nighttime Auto-Exposure is on.", +"description" : "When starting Allsky at night, skip up to this many frames while the software gets to the correct exposure.", "label" : "Frames To Skip", +"label_prefix" : "Nighttime", "type" : "integer", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"booldependson" : "nightautoexposure", +"action" : "reload" }, { -"name" : "nightEnableCooler", -"default" : 0, -"description" : "Activate to use cooling (works only on cooled cameras).", +"name" : "nightenablecooler", +"display" : "_display", +"default" : false, +"description" : "Enable to use cooling (on cooled cameras only).", "label" : "Cooling", +"label_prefix" : "Nighttime", "type" : "boolean", -"generic" : 1, -"display" : "_display" +"usage" : "capture", +"carryforward" : true, +"action" : "reload" }, { -"name" : "nightTargetTemp", +"name" : "nighttargettemp", +"display" : "_display", "minimum" : "_min", "maximum" : "_max", "default" : "_default", "description" : "Sensor's target temperature when cooler is enabled (degrees Celsius).", "label" : "Target Temp.", +"label_prefix" : "Nighttime", "type" : "integer", -"generic" : 1, -"display" : "_display" +"usage" : "capture", +"booldependson" : "nightenablecooler", +"action" : "reload" }, { -"name" : "nightTuningFile", +"name" : "nighttuningfile", +"display" : "_display", "default" : "", "description" : "Name of the night camera tuning file to use.", "label" : "Tuning File", +"label_prefix" : "Nighttime", "type" : "widetext", -"checkchanges" : 1, -"optional" : 1, -"display" : "_display" +"usage" : "capture", +"checkchanges" : true, +"optional" : true, +"action" : "restart" }, + { -"name" : "DayAndNightHeader", -"description" : "Both daytime and nighttime settings", -"type" : "header", -"display" : 1 +"name" : "bothtab========================================", +"display" : false, +"label" : "24 Hours", +"type" : "header-tab" +}, +{ +"name" : "dayandnightheader", +"label" : "Both Day and Night Settings", +"type" : "header" +}, +{ +"name" : "daystokeep", +"minimum" : 0, +"maximum" : "none", +"default" : 14, +"description" : "Number of days of images and videos to keep on the Pi. 0 keeps all days.", +"label" : "Days To Keep", +"type" : "integer", +"carryforward" : true, +"checkchanges" : true, +"booldependson" : "savedaytimeimages OR savenighttimeimages" }, { "name" : "config", @@ -404,122 +575,108 @@ "description" : "Configuration file to use for settings.", "label" : "Configuration File", "type" : "widetext", -"checkchanges" : 1, -"optional" : 1, -"display" : 1 +"usage" : "capture", +"checkchanges" : true, +"optional" : true, +"action" : "reload" }, { -"name" : "extraArgs", +"name" : "extraargs", +"display" : "_display", "default" : "", "description" : "Extra arguments to pass to the capture program that Allsky doesn't support.", "label" : "Extra Arguments", "type" : "widetext", -"optional" : 1, -"display" : "_display" +"usage" : "capture", +"optional" : true, +"action" : "reload" }, { "name" : "saturation", +"display" : "_display", "minimum" : "_min", "maximum" : "_max", "default" : "_default", "description" : "Changes the saturation level of the image.
The lowest level produces a black and white image.", "label" : "Saturation", "type" : "float", -"display" : "_display" +"usage" : "capture", +"action" : "reload" }, { "name" : "contrast", +"display" : "_display", "minimum" : "_min", "maximum" : "_max", "default" : "_default", "description" : "Changes the contrast of an image.", "label" : "Contrast", "type" : "float", -"display" : "_display" +"usage" : "capture", +"action" : "reload" }, { "name" : "sharpness", +"display" : "_display", "minimum" : "_min", "maximum" : "_max", "default" : "_default", "description" : "Changes the sharpness of an image.
Too sharp and images look unnatural.", "label" : "Sharpness", "type" : "float", -"display" : "_display" +"usage" : "capture", +"action" : "reload" }, { "name" : "gamma", +"display" : "_display", "minimum" : "_min", "maximum" : "_max", "default" : "_default", -"description" : "Changes the difference between light and dark areas.
higher numbers increase contrast.", +"description" : "Changes the difference between light and dark areas.
Higher numbers increase contrast.", "label" : "Gamma", "type" : "integer", -"display" : "_display" -}, -{ -"name" : "offset", -"minimum" : "_min", -"maximum" : "_max", -"default" : "_default", -"description" : "Adds about 1/10 the specified number to every pixel to brighten everything.", -"label" : "Offset", -"type" : "integer", -"display" : "_display" +"usage" : "capture", +"action" : "reload" }, { "name" : "aggression", +"display" : "_display", "minimum" : "_min", "maximum" : "_max", "default" : "_default", "description" : "How much of a calculated exposure change should be made during Auto-Exposure?
Lower numbers smooth out changes but take longer to react to brightness changes.", "label" : "Aggression", "type" : "percent", -"generic" : 1, -"display" : "_display" +"usage" : "capture", +"booldependson" : "dayautoexposure OR nightautoexposure", +"action" : "reload" }, { "name" : "gaintransitiontime", +"display" : "_display", "minimum" : 0, "maximum" : "none", "default" : "_default", -"description" : "Number of minutes over which to increase or decrease the gain when switching between day and night.
This helps smooth brightness differences. Only works if nighttime Auto-Gain is off. 0 disables transitions.", +"description" : "Number of minutes over which to increase or decrease the gain when switching between day and night.
This helps smooth brightness differences. 0 disables transitions.", "label" : "Gain Transition Time", "type" : "integer", -"generic" : 1, -"display" : "_display" -}, -{ -"name" : "width", -"minimum" : "_min", -"maximum" : "_max", -"default" : 0, -"description" : "Image width in pixels. 0 = max sensor width.", -"label" : "Image Width", -"type" : "integer", -"display" : 1 -}, -{ -"name" : "height", -"minimum" : "_min", -"maximum" : "_max", -"default" : 0, -"description" : "Image height in pixels. 0 = max sensor height.", -"label" : "Image Height", -"type" : "integer", -"display" : 1 +"usage" : "capture", +"booldependsoff" : "nightautogain", +"action" : "reload" }, { "name" : "type", "default" : 99, "description" : "'auto' uses color if possible, otherwise the best mono mode supported.
RAW16 only works with PNG extension.", "label" : "Image Type", -"type" : "select", +"type" : "select_integer", +"usage" : "capture", "options" : [ "type_values" ], -"checkchanges" : 1, -"display" : 1 +"checkchanges" : true, +"action" : "reload" }, { "name" : "quality", @@ -529,26 +686,32 @@ "description" : "JPG quality: 0-100. Higher numbers produce larger, clearer files.
PNG compression: 0-9. Higher numbers produce smaller files but take longer to save.", "label" : "Quality / Compression", "type" : "integer", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"action" : "reload" }, { "name" : "autousb", -"default" : 1, +"display" : "_display", +"default" : true, "description" : "Automatically set the USB bandwidth.", "label" : "Auto USB Bandwidth", "type" : "boolean", -"display" : "_display" +"usage" : "capture", +"action" : "reload" }, { "name" : "usb", +"display" : "_display", "minimum" : "_min", "maximum" : "_max", "default" : "_default", "description" : "USB bandwidth.
If you get ASI_ERROR_TIMEOUT errors in the log file, try changing this number.", "label" : "USB Bandwidth", "type" : "integer", -"display" : "_display" +"usage" : "capture", +"booldependsoff" : "autousb", +"action" : "reload" }, { "name" : "filename", @@ -556,52 +719,57 @@ "description" : "Extensions allowed: .jpg or .png.
WARNING: saving a .png file can take 10 or more seconds so be sure the time between successive images is long enough. To measure how long it takes your Pi to save a .png file, set the Debug Level to 4 and look in the log file.", "label" : "Filename", "type" : "text", -"checkchanges" : 1, -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"checkchanges" : true, +"action" : "reload" }, { "name" : "rotation", +"display" : "_display", "default" : 0, "description" : "Set image rotation to enable proper orientation.", "label" : "Rotation", -"type" : "select", +"type" : "select_integer", +"usage" : "capture", "options" : [ "rotation_values" ], -"display" : "_display" +"action" : "reload" }, { "name" : "flip", "default" : 0, "description" : "Flips the image along an axis.", "label" : "Flip", -"type" : "select", +"type" : "select_integer", +"usage" : "capture", "options" : [ {"value" : 0, "label" : "None"}, {"value" : 1, "label" : "Horizontal"}, {"value" : 2, "label" : "Vertical"}, {"value" : 3, "label" : "Both"} ], -"display" : 1 +"action" : "reload" }, { -"name" : "notificationimages", -"default" : 1, -"description" : "Activate to display notification images, e.g., 'Camera is off during the day'.
While these messages appear in the WebUI and Allsky Website, they are not saved so don't appear in timelapse, startrails, or keograms.", -"label" : "Notification Images", +"name" : "determinefocus", +"default" : false, +"description" : "Enable to have the image focus determined and output via the 'FOCUS' variable.", +"label" : "Record Focus", "type" : "boolean", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"action" : "stop" }, { -"name" : "consistentDelays", -"default" : 1, -"description" : "Activate to have the delays between images be a consistent length.
The time between the start of exposures will always be (max exposure + delay).", +"name" : "consistentdelays", +"default" : true, +"description" : "Enable to have the delays between images be a consistent length.
The time between the start of exposures will always be (max exposure + delay).", "label" : "Consistent Delays Between Images", "type" : "boolean", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"action" : "reload" }, { "name" : "timeformat", @@ -609,28 +777,46 @@ "description" : "Determines the format of the displayed time. Run 'man 3 strftime' to see the options.", "label" : "Time Format", "type" : "text", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"action" : "reload" +}, +{ +"name" : "temptype", +"default" : "C", +"description" : "Unit(s) to display temperature in.", +"label" : "Temperature Units", +"type" : "select_text", +"usage" : "capture", +"carryforward" : true, +"options" : [ + {"value" : "C", "label" : "Celsius"}, + {"value" : "F", "label" : "Fahrenheit"}, + {"value" : "B", "label" : "Both"} +], +"action" : "reload" }, { "name" : "latitude", "default" : "", -"description" : "Camera latitude - a number with a sign (-90 to +90) or a positive number with direction (N or S), for example: 60.7N.", +"description" : "Camera latitude: a signed number from -90 to +90, or an unsigned number with direction (N or S), for example: 60.7N.", "label" : "Latitude", "type" : "text", -"checkchanges" : 1, -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"checkchanges" : true, +"action" : "reload" }, { "name" : "longitude", "default" : "", -"description" : "Camera longitude - a number with a sign (-180 to +180) or a positive number with a direction (E or W), for example: 135.05W.", +"description" : "Camera longitude: a signed number from -180 to +180, or an unsigned number with a direction (E or W), for example: 135.05W.", "label" : "Longitude", "type" : "text", -"checkchanges" : 1, -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"checkchanges" : true, +"action" : "reload" }, { "name" : "angle", @@ -638,58 +824,72 @@ "description" : "Altitude of the Sun in degrees relative to the horizon at which to switch between day and night.", "label" : "Angle", "type" : "float", -"checkchanges" : 1, -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"checkchanges" : true, +"action" : "reload" }, { -"name" : "takeDarkFrames", -"default" : 0, -"description" : "Activate to capture dark frames, which are used to decrease noise.
Continues taking dark frames until Allsky is restarted.", +"name" : "takedarkframes", +"default" : false, +"description" : "Enable to capture dark frames, which are used to decrease noise.
See the Dark frames documentation on how to take dark frames.", "label" : "Take Dark Frames", "type" : "boolean", -"display" : 1 +"usage" : "capture", +"action" : "stop" }, { -"name" : "useDarkFrames", -"default" : 0, +"name" : "usedarkframes", +"default" : false, "description" : "Enables dark frame subtraction if you have dark frames.", "label" : "Use Dark Frames", "type" : "boolean", -"generic" : 1, -"display" : 1 +"checkchanges" : true }, { "name" : "locale", "default" : "", -"description" : "Your locale, used to determine what the thousands and decimal separators are.
Type locale at a command prompt to see your choices.", +"description" : "Your locale, used to determine what the thousands and decimal separators are.
Type locale --all-locales at a command prompt to see which locales are configured on your Pi.", "label" : "Locale", "type" : "text", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"action" : "reload" }, { -"name" : "experimentalExposure", +"name" : "zwoexposuretype", +"display" : "_display", "default" : 0, -"description" : "Activate to use a newer auto-exposure algorithm at night. Initial testing indictes the images taken during the day-to-night transition as well as at night have better exposures.
If you use this, please add a Discussion item describing your results - good or bad. We need the feedback.", -"label" : "New Exposure Algorithm", -"type" : "boolean", -"display" : "_display" +"description" : "Select the type of exposure to use with ZWO cameras. Use Snapshot if possible since it should fix ASI_ERROR_TIMEOUT problems. If that doesn't work try Video Off. Note that Auto White Balance does not work in Snapshot", +"label" : "ZWO Exposure Type", +"type" : "select_integer", +"usage" : "capture", +"options" : [ + {"value" : 0, "label" : "Snapshot"}, + {"value" : 1, "label" : "Video Off"}, + {"value" : 2, "label" : "Video (original)"} +], +"action" : "reload" }, { "name" : "histogrambox", +"display" : "_display", "default" : "500 500 50 50", "description" : "Size of the histogram box (X and Y in pixels) and offset from left and top (0-100 percent). Sizes must be even numbers.", "label" : "Histogram Box", "type" : "text", -"display" : "_display" +"usage" : "capture", +"booldependson" : "dayautoexposure OR nightautoexposure", +"action" : "reload" }, { "name" : "debuglevel", "default" : 1, -"description" : "Debug level. 0 is errors only. 4 is for Allsky developers use.", +"description" : "Debug level. 0 is errors only. 4 is for Allsky developers use.", "label" : "Debug Level", -"type" : "select", +"type" : "select_integer", +"usage" : "capture", +"carryforward" : true, "options" : [ {"value" : 0, "label" : 0}, {"value" : 1, "label" : 1}, @@ -697,137 +897,125 @@ {"value" : 3, "label" : 3}, {"value" : 4, "label" : 4} ], -"display" : 1 -}, -{ -"name" : "newexposure", -"default" : 1, -"description" : "Activate to use the Allsky version 0.8 exposure method which decreases sensor temperature. If you see ASI_ERROR_TIMEOUTs in the log file, deactivate this.", -"label" : "Version 0.8 Exposure", -"type" : "boolean", -"display" : "_display" +"action" : "reload" }, + + { -"name" : "useLogin", -"default" : 1, -"description" : "Determines if you need to login to the WebUI or not.
If your Pi is accessible on the Internet, do NOT turn this off!!.", -"label" : "Require WebUI Login", -"type" : "boolean", -"generic" : 1, -"display" : 1 +"name" : "overlaytab========================================", +"label" : "Overlay", +"type" : "header-tab" }, { -"name" : "overlayHeader", -"description" : "Image overlay settings", -"type" : "header", -"display" : 1 +"name" : "overlayheader", +"label" : "Image Overlay Settings", +"type" : "header" }, { -"name" : "overlayMethod", -"default" : 0, +"name" : "overlaymethod", +"default" : 1, "description" : "Determines how image overlays are done.
module supports a visual editor.
When set to module, the overlay settings below do NOT apply.", "label" : "Overlay Method", -"type" : "select", +"usage" : "capture", +"type" : "select_integer", "options" : [ {"value" : 0, "label" : "legacy"}, {"value" : 1, "label" : "module"} ], -"checkchanges" : 1, -"generic" : 1, -"display" : 1 +"carryforward" : true, +"checkchanges" : true, +"action" : "reload" }, { -"name" : "showTime", -"default" : 1, -"description" : "Activate to display the time an image was taken on the overlay.", +"name" : "showtime", +"default" : true, +"description" : "Enable to display the time an image was taken on the overlay.", "label" : "Show Time", "type" : "boolean", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { -"name" : "showTemp", -"default" : 1, -"description" : "Activate to display the camera sensor temperature on the overlay.", +"name" : "showtemp", +"display" : "_display", +"default" : true, +"description" : "Enable to display the camera sensor temperature on the overlay.", "label" : "Show Temperature", "type" : "boolean", -"generic" : 1, -"display" : "_display" -}, -{ -"name" : "temptype", -"default" : "C", -"description" : "Unit(s) to display temperature in.", -"label" : "Temperature Units", -"type" : "select", -"options" : [ - {"value" : "C", "label" : "Celcius"}, - {"value" : "F", "label" : "Fahrenheit"}, - {"value" : "B", "label" : "Both"} -], -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { -"name" : "showExposure", -"default" : 1, -"description" : "Activate to display the exposure length on the overlay.
If Auto-Exposure is set, '(auto)' will appear after the exposure time.", +"name" : "showexposure", +"default" : true, +"description" : "Enable to display the exposure length on the overlay.
If Auto-Exposure is set, '(auto)' will appear after the exposure time.", "label" : "Show Exposure", "type" : "boolean", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { -"name" : "showGain", -"default" : 0, -"description" : "Activate to display the camera gain on the overlay.
If Auto-Gain is set, '(auto)' will appear after the gain value.", +"name" : "showgain", +"default" : false, +"description" : "Enable to display the camera gain on the overlay.
If Auto-Gain is set, '(auto)' will appear after the gain value.", "label" : "Show Gain", "type" : "boolean", -"generic" : 1, -"display" : 1 -}, -{ -"name" : "showBrightness", -"default" : 0, -"description" : "Activate to display the Brightness number you set on the overlay.", -"label" : "Show Brightness", -"type" : "boolean", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { -"name" : "showUSB", -"default" : 0, -"description" : "Activate to display the USB Bandwidth number on the overlay.
This is primarily for troubleshooting.", +"name" : "showusb", +"display" : "_display", +"default" : false, +"description" : "Enable to display the USB Bandwidth number on the overlay.
This is primarily for troubleshooting.", "label" : "Show USB", "type" : "boolean", -"display" : "_display" +"usage" : "capture", +"carryforward" : true, +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { -"name" : "showMean", -"default" : 0, -"description" : "Activate to display the calculated mean image brightness on the overlay.", +"name" : "showmean", +"default" : false, +"description" : "Enable to display the calculated mean image brightness on the overlay.", "label" : "Show Mean Brightness", "type" : "boolean", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { "name" : "showhistogrambox", -"default" : 0, -"description" : "Activate to show an outline of the histogram box, useful for determining what the 'Histogram Box' numbers should be.", +"display" : "_display", +"default" : false, +"description" : "Enable to show an outline of the histogram box, useful for determining what the 'Histogram Box' numbers should be.", "label" : "Show Histogram Box", "type" : "boolean", -"display" : "_display" +"usage" : "capture", +"carryforward" : true, +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { -"name" : "showFocus", -"default" : 0, -"description" : "Activate to display a focus metric to help you focus the camera.
This is only usefull while focusing.", +"name" : "showfocus", +"default" : false, +"description" : "Enable to display a focus metric to help you focus the camera.
This is only usefull while focusing.", "label" : "Show Focus Metric", "type" : "boolean", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { "name" : "text", @@ -835,9 +1023,10 @@ "description" : "Text that appears below the optional time with the same color and size.", "label" : "Text Overlay", "type" : "widetext", -"optional" : 1, -"generic" : 1, -"display" : 1 +"usage" : "capture", +"optional" : true, +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { "name" : "extratext", @@ -845,10 +1034,11 @@ "description" : "Extra Text File (enter full path to the file).
Leave blank if NOT using an extra text file.", "label" : "Extra Text File", "type" : "widetext", -"checkchanges" : 1, -"optional" : 1, -"generic" : 1, -"display" : 1 +"usage" : "capture", +"checkchanges" : true, +"optional" : true, +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { "name" : "extratextage", @@ -858,8 +1048,11 @@ "description" : "The maximum age of the Extra Text File in seconds.
After this time the contents of the file will no longer be displayed. Set to 0 to always display.", "label" : "Max Age Of Extra", "type" : "integer", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"valuedependson" : "extratext=*", +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { "name" : "textlineheight", @@ -869,8 +1062,9 @@ "description" : "The line height of the text in pixels. If increasing the font size causes the text to overlap then increase this number.", "label" : "Line Height", "type" : "integer", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { "name" : "textx", @@ -880,8 +1074,9 @@ "description" : "Text will begin this many pixels from the left.", "label" : "Text X", "type" : "integer", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { "name" : "texty", @@ -891,15 +1086,18 @@ "description" : "Text will begin this many pixels from the top.", "label" : "Text Y", "type" : "integer", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { "name" : "fontname", "default" : 0, "description" : "Font name for the text overlay. Used for both large and small text.", "label" : "Font Name", -"type" : "select", +"usage" : "capture", +"carryforward" : true, +"type" : "select_integer", "options" : [ {"value" : 0, "label" : "Simplex"}, {"value" : 1, "label" : "Plain"}, @@ -910,8 +1108,8 @@ {"value" : 6, "label" : "Script Simplex"}, {"value" : 7, "label" : "Script Complex"} ], -"generic" : 1, -"display" : 1 +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { "name" : "fontcolor", @@ -920,8 +1118,9 @@ "description" : "Blue, Green, and Red (BGR) values of text color.
NOTE: When using RAW 16 only the first two numbers are used i.e., 255 128 0.", "label" : "Font Color", "type" : "text", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { "name" : "smallfontcolor", @@ -929,22 +1128,24 @@ "description" : "BGR value of text color.
NOTE: When using RAW 16 only the first two numbers are used i.e., 255 128 0.", "label" : "Small Font Color", "type" : "text", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { "name" : "fonttype", "default" : 0, "description" : "Choose between smooth font lines (antialiased), 8-pixel, or 4 pixel connectivity (harder edges).", "label" : "Font Smoothness", -"type" : "select", +"type" : "select_integer", +"usage" : "capture", "options" : [ {"value" : 0, "label" : "Antialiased"}, {"value" : 1, "label" : "8 connected"}, {"value" : 2, "label" : "4 connected"} ], -"generic" : 1, -"display" : 1 +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { "name" : "fontsize", @@ -954,8 +1155,9 @@ "description" : "Text Font Size. The time, if displayed, will use this size; other text displayed will be about 20% smaller.", "label" : "Font Size", "type" : "float", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { "name" : "fontline", @@ -965,160 +1167,1346 @@ "description" : "How thick the text line should be.", "label" : "Font Weight", "type" : "integer", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"booldependsoff" : "overlaymethod", +"action" : "reload" }, { "name" : "outlinefont", -"default" : 0, +"default" : false, "description" : "Adds a black outline to the text overlay to improve contrast.", "label" : "Use Outline Font", "type" : "boolean", -"generic" : 1, -"display" : 1 +"usage" : "capture", +"carryforward" : true, +"booldependsoff" : "overlaymethod", +"action" : "reload" }, + { -"name" : "AllskyMapHeader", -"description" : "Allsky Map and Website Settings", -"type" : "header", -"display" : 1 +"name" : "postcapturetab========================================", +"display" : false, +"label" : "Post Capture", +"type" : "header-tab" }, { -"name" : "displaySettings", -"default" : 0, -"description" : "Activate to add a link to the Allsky Website to display the settings on this page.
Only applies if you have a local or remote Allsky Website.", -"label" : "Display Settings", +"name" : "postcaptureheader", +"label" : "Post Capture Settings", +"type" : "header" +}, +{ +"name" : "imageremovebadlow", +"minimum" : 0, +"maximum" : 0.5, +"default" : 0.1, +"description" : "Delete images whose mean is below this value.
0 disables this check.", +"label" : "Remove Bad Images Threshold Low", +"type" : "float", +"booldependson" : "savedaytimeimages OR savenighttimeimages" +}, +{ +"name" : "imageremovebadhigh", +"minimum" : 0, +"maximum" : 0.5, +"default" : 0.9, +"description" : "Delete images whose mean is above this value.
0 disables this check.", +"label" : "Remove Bad Images Threshold High", +"type" : "float", +"booldependson" : "savedaytimeimages OR savenighttimeimages" +}, +{ +"name" : "imagecreatethumbnails", +"default" : true, +"description" : "Enable to create thumbnails of images. If you never look at them, disable this.", +"label" : "Create Image Thumbnails", "type" : "boolean", -"checkchanges" : 1, -"generic" : 1, -"display" : 1 +"carryforward" : true, +"booldependson" : "savedaytimeimages OR savenighttimeimages" }, { -"name" : "showonmap", +"name" : "thumbnailsizex", +"minimum" : 25, +"maximum" : "500", +"default" : 100, +"description" : "Width of image and video thumbnails.", +"label" : "Thumbnail Width", +"type" : "integer", +"carryforward" : true, +"booldependson" : "savedaytimeimages OR savenighttimeimages" +}, +{ +"name" : "thumbnailsizey", +"minimum" : 20, +"maximum" : "400", +"default" : 75, +"description" : "Height of image and video thumbnails.", +"label" : "Thumbnail Height", +"type" : "integer", +"carryforward" : true, +"booldependson" : "savedaytimeimages OR savenighttimeimages" +}, +{ +"name" : "imageresizewidth", +"minimum" : 2, +"maximum" : "none", "default" : 0, -"description" : "Activate to have your camera appear on the Allsky Map .", -"label" : "Show on Map", +"description" : "Width of resized image before cropping. 0 means don't resize.", +"label" : "Image Resize Width", +"type" : "integer", +"checkchanges" : true, +"booldependson" : "takedaytimeimages OR takenighttimeimages" +}, +{ +"name" : "imageresizeheight", +"minimum" : 2, +"maximum" : "none", +"default" : 0, +"description" : "Height of resized image before cropping. 0 means don't resize.", +"label" : "Image Resize Height", +"type" : "integer", +"checkchanges" : true, +"booldependson" : "takedaytimeimages OR takenighttimeimages" +}, +{ +"name" : "imagecroptop", +"minimum" : 0, +"maximum" : "_max", +"default" : 0, +"description" : "Number of pixels to crop off the top of image.", +"label" : "Image Crop Top", +"type" : "integer", +"checkchanges" : true, +"booldependson" : "takedaytimeimages OR takenighttimeimages" +}, +{ +"name" : "imagecropright", +"minimum" : 0, +"maximum" : "_max", +"default" : 0, +"description" : "Number of pixels to crop off the right side of image.", +"label" : "Image Crop Right", +"type" : "integer", +"checkchanges" : true, +"booldependson" : "takedaytimeimages OR takenighttimeimages" +}, +{ +"name" : "imagecropbottom", +"minimum" : 0, +"maximum" : "_max", +"default" : 0, +"description" : "Number of pixels to crop off the bottom of image.", +"label" : "Image Crop Bottom", +"type" : "integer", +"checkchanges" : true, +"booldependson" : "takedaytimeimages OR takenighttimeimages" +}, +{ +"name" : "imagecropleft", +"minimum" : 0, +"maximum" : "_max", +"default" : 0, +"description" : "Number of pixels to crop off the left side of image.", +"label" : "Image Crop Left", +"type" : "integer", +"checkchanges" : true, +"booldependson" : "takedaytimeimages OR takenighttimeimages" +}, + +{ +"name" : "timelapsetab========================================", +"label" : "Timelapses", +"type" : "header-tab" +}, +{ +"name" : "timelapseheader", +"label" : "Timelapse Settings", +"type" : "header" +}, +{ +"name" : "timelapsesubheader", +"label" : "Daily Timelapse", +"type" : "header-sub" +}, +{ +"name" : "timelapsegenerate", +"default" : true, +"description" : "Enable to generate a timelapse video at the end of night.", +"label" : "Generate", +"label_prefix" : "Daily Timelapse", "type" : "boolean", -"checkchanges" : 1, -"generic" : 1, -"display" : 1 +"carryforward" : true, +"booldependson" : "savedaytimeimages OR savenighttimeimages" }, { -"name" : "websiteurl", -"default" : "", -"description" : "The URL of your Allsky Website, for example: https://www.thomasjacquin.com/allsky.
If your camera is not accessible on the Internet, leave this field empty.
Must begin with http or https.", -"label" : "Website URL", -"type" : "widetext", -"checkchanges" : 1, -"optional" : 1, -"generic" : 1, -"display" : 1 +"name" : "timelapseupload", +"default" : true, +"description" : "Enable to upload the timelapse video to an Allsky Website and/or remote server.", +"label" : "Upload", +"label_prefix" : "Daily Timelapse", +"type" : "boolean", +"carryforward" : true, +"booldependson" : "timelapsegenerate" }, { -"name" : "imageurl", -"default" : "", -"description" : "The URL of the image on your Allsky Website, for example: https://www.thomasjacquin.com/allsky/image.jpg.
Right-click on the image and select Copy Image Address.
If your camera is not accessible on the Internet, leave this field empty.
Must begin with http or https.", -"label" : "Image URL", -"type" : "widetext", -"checkchanges" : 1, -"optional" : 1, -"generic" : 1, -"display" : 1 +"name" : "timelapseuploadthumbnail", +"default" : true, +"description" : "Enable to upload the timelapse video thumbnail.", +"label" : "Upload Thumbnail", +"label_prefix" : "Daily Timelapse", +"type" : "boolean", +"carryforward" : true, +"booldependson" : "timelapsegenerate AND timelapseupload AND (uselocalwebsite OR useremotewebsite OR useremoteserver)" }, { -"name" : "location", -"default" : "", -"description" : "The location of your camera, for example: Whitehorse, YT.
No need to enter country since it'll be obvious looking at the map.
This setting and the remaining ones also appear on the Allsky Website, if installed.", -"label" : "Location", -"type" : "widetext", -"checkchanges" : 1, -"optional" : 1, -"generic" : 1, -"display" : 1 +"name" : "timelapsewidth", +"minimum" : 0, +"maximum" : "_max", +"default" : 0, +"description" : "Width of resized timelapse video. 0 disables resize.", +"label" : "Width", +"label_prefix" : "Daily Timelapse", +"type" : "integer", +"booldependson" : "timelapsegenerate" }, { -"name" : "owner", -"default" : "", -"description" : "The owner of the camera. It can be your name or an association or observatory, etc.", -"label" : "Owner", -"type" : "text", -"checkchanges" : 1, -"optional" : 1, -"generic" : 1, -"display" : 1 +"name" : "timelapseheight", +"minimum" : 0, +"maximum" : "_max", +"default" : 0, +"description" : "Height of resized timelapse video. 0 disables resize.", +"label" : "Height", +"label_prefix" : "Daily Timelapse", +"type" : "integer", +"booldependson" : "timelapsegenerate" }, { -"name" : "camera", -"default" : "", -"description" : "The type and model of your camera, for example: ZWO 224MC or RPi HQ.", -"label" : "Camera", -"type" : "widetext", -"checkchanges" : 1, -"optional" : 1, -"display" : 1 +"name" : "timelapsebitrate", +"minimum" : 100, +"maximum" : 30000, +"default" : 5000, +"description" : "Bitrate (in K) of timelapse video. Higher values produce higher quality but larger files. Do NOT add k.", +"label" : "Bitrate", +"label_prefix" : "Daily Timelapse", +"type" : "integer", +"booldependson" : "timelapsegenerate" }, { -"name" : "lens", -"default" : "", -"description" : "The lens you're using on your camera, for example: Arecont 1.55.", -"label" : "Lens", -"type" : "text", -"checkchanges" : 1, -"optional" : 1, -"display" : 1 +"name" : "timelapsefps", +"minimum" : 1, +"maximum" : 200, +"default" : 25, +"description" : "Frames Per Second (FPS) of timelapse video. Higher values will produce smoother, but faster videos.", +"label" : "FPS", +"label_prefix" : "Daily Timelapse", +"type" : "integer", +"carryforward" : true, +"booldependson" : "timelapsegenerate" }, { -"name" : "computer", +"name" : "timelapsekeepsequence", +"default" : false, +"description": "Use for debugging purposes only. Enable to keep the list of files used in creating the timelapse video.", +"label" : "Keep Sequence", +"label_prefix" : "Daily Timelapse", +"type" : "boolean", +"booldependson" : "timelapsegenerate" +}, +{ +"name" : "timelapseextraparameters", "default" : "", -"description" : "The computer running your allsky camera, for example: Raspberry Pi 3, 4 GB.", -"label" : "Computer", +"description" : "Optional additional timelapse creation parameters. Run ffmpeg -? to see the options.
If quality is poor or the video does not plan on Apple devices, try adding -level 3.1.", +"label" : "Extra Parameters", +"label_prefix" : "Daily Timelapse", "type" : "widetext", -"checkchanges" : 1, -"optional" : 1, -"generic" : 1, -"display" : 1 +"carryforward" : true, +"optional" : true, +"booldependson" : "timelapsegenerate" }, + { -"name" : "CameraHeading", -"description" : "Camera Type", -"type" : "header", -"display" : 1 +"name" : "minitimelapseheader", +"label" : "Mini-Timelapse", +"type" : "header-sub" }, { -"name" : "cameraType", -"default" : "", -"description" : "The type of camera you are using.
Select Refresh to reset the Camera Type and Model.", -"label" : "Camera Type", -"type" : "select", -"options" : [ - {"value" : "RPi", "label" : "RPi"}, - {"value" : "ZWO", "label" : "ZWO"}, - {"value" : "Refresh", "label" : "Refresh"} -], -"checkchanges" : 1, -"display" : 1 +"name" : "minitimelapsenumimages", +"minimum" : 0, +"maximum" : "none", +"default" : 0, +"description" : "Number of images in a mini-timelapse. 0 disables mini-timelapse creation.", +"label" : "Number Of Images", +"label_prefix" : "Mini-Timelapse", +"type" : "integer", +"carryforward" : true, +"booldependson" : "savedaytimeimages OR savenighttimeimages" }, { -"name" : "cameraModel", -"default" : "", -"description" : "The model of camera you are using.
To change it, change the Camera Type above.", -"label" : "Camera Model", -"type" : "readonly", -"display" : 1 +"name" : "minitimelapseforcecreation", +"default" : false, +"description" : "Create a mini-timelapse even if Number Of Images isn't reached?", +"label" : "Force Creation", +"label_prefix" : "Mini-Timelapse", +"type" : "boolean", +"carryforward" : true, +"valuedependson" : "minitimelapsenumimages=[1-9]*" }, { -"name" : "cameraNumber", -"minimum" : 0, +"name" : "minitimelapsefrequency", +"minimum" : 1, "maximum" : "none", +"default" : 5, +"description" : "Make a mini-timelapse every X images.
If you have a slow Pi or short delays between images, set this to a higher number (i.e., not as often).", +"label" : "Frequency", +"label_prefix" : "Mini-Timelapse", +"type" : "integer", +"carryforward" : true, +"valuedependson" : "minitimelapsenumimages=[1-9]*" +}, +{ +"name" : "minitimelapseupload", +"default" : false, +"description" : "Enable to upload the mini-timelapse video to an Allsky Website and/or remote server.", +"label" : "Upload", +"label_prefix" : "Mini-Timelapse", +"type" : "boolean", +"carryforward" : true, +"valuedependson" : "minitimelapsenumimages=[1-9]*" +}, +{ +"name" : "minitimelapseuploadthumbnail", +"default" : true, +"description" : "Enable to upload the mini-timelapse video thumbnail.", +"label" : "Upload Thumbnail", +"label_prefix" : "Mini-Timelapse", +"type" : "boolean", +"carryforward" : true, +"booldependson" : "minitimelapseupload AND (uselocalwebsite OR useremotewebsite OR useremoteserver)", +"valuedependson" : "minitimelapsenumimages=[1-9]*" +}, +{ +"name" : "minitimelapsewidth", +"minimum" : 0, +"maximum" : "_max", +"default" : 0, +"description" : "Width of resized mini-timelapse video. 0 disables resize.", +"label" : "Width", +"label_prefix" : "Mini-Timelapse", +"type" : "integer", +"valuedependson" : "minitimelapsenumimages=[1-9]*" +}, +{ +"name" : "minitimelapseheight", +"minimum" : 0, +"maximum" : "_max", +"default" : 0, +"description" : "Height of resized mini-timelapse video. 0 disables resize.", +"label" : "Height", +"label_prefix" : "Mini-Timelapse", +"type" : "integer", +"valuedependson" : "minitimelapsenumimages=[1-9]*" +}, +{ +"name" : "minitimelapsebitrate", +"minimum" : 100, +"maximum" : 30000, +"default" : 2000, +"description" : "Bitrate (in K) of timelapse video. Higher values produce higher quality but larger files. Do NOT add k.", +"label" : "Bitrate", +"label_prefix" : "Mini-Timelapse", +"type" : "integer", +"carryforward" : true, +"valuedependson" : "minitimelapsenumimages=[1-9]*" +}, +{ +"name" : "minitimelapsefps", +"minimum" : 1, +"maximum" : 200, +"default" : 5, +"description" : "Frames Per Second (FPS) of mini-timelapse video.", +"label" : "FPS", +"label_prefix" : "Mini-Timelapse", +"type" : "integer", +"carryforward" : true, +"valuedependson" : "minitimelapsenumimages=[1-9]*" +}, +{ +"name" : "timelapsebothsubheader", +"label" : "Both Timelapses", +"type" : "header-sub" +}, +{ +"name" : "timelapsevcodec", +"default" : "libx264", +"description" : "Video encoder for timelapse. Rarely changed.", +"label" : "VCODEC", +"label_prefix" : "Timelapse", +"type" : "text", +"carryforward" : true, +"comment" : "check using ffmpeg -encoder looking at argument 2 for ' libxs264 '", +"booldependson" : "timelapsegenerate", +"checkchanges" : true +}, +{ +"name" : "timelapsepixfmt", +"default" : "yuv420p", +"description" : "Pixel format for timelapse. Rarely changed.", +"label" : "Pixel format", +"label_prefix" : "Timelapse", +"type" : "text", +"carryforward" : true, +"booldependson" : "timelapsegenerate", +"comment" : "check using ffmpeg -pix_fmts looking at argument 2 for ' yuv420p '", +"checkchanges" : true +}, +{ +"name" : "timelapsefflog", +"default" : "warning", +"description" : "Amount of information to display while creating a timelapse. Rarely changed.", +"label" : "Log Level", +"label_prefix" : "Timelapse", +"type" : "select_text", +"carryforward" : true, +"options" : [ + { "value" : "quiet", "label" : "No output" }, + { "value" : "fatal", "label" : "Fatal Errors" }, + { "value" : "error", "label" : "Errors" }, + { "value" : "warning", "label" : "Warnings + Errors" }, + { "value" : "info", "label" : "Info + Warnings + Errors" } +], +"booldependson" : "timelapsegenerate" +}, + +{ +"name" : "keogramstartrailstab========================================", +"label" : "Keogram and Startrails", +"type" : "header-tab" +}, +{ +"name" : "timelapseheader", +"label" : "Keogram and Startrails Settings", +"type" : "header" +}, +{ +"name" : "keogramsubheader", +"label" : "Keograms", +"type" : "header-sub" +}, +{ +"name" : "keogramgenerate", +"default" : true, +"description" : "Enable to generate a keogram at the end of night.", +"label" : "Generate", +"label_prefix" : "Keograms", +"type" : "boolean", +"carryforward" : true, +"booldependson" : "savedaytimeimages OR savenighttimeimages" +}, +{ +"name" : "keogramupload", +"default" : true, +"description" : "Enable to upload the keogram to an Allsky Website and/or remote server.", +"label" : "Upload", +"label_prefix" : "Keograms", +"type" : "boolean", +"carryforward" : true, +"booldependson" : "keogramgenerate AND (uselocalwebsite OR useremotewebsite OR useremoteserver)" +}, + +{ +"name" : "keogramexpand", +"default" : true, +"description" : "Enable to expand keograms to the image width.", +"label" : "Expand", +"label_prefix" : "Keograms", +"type" : "boolean", +"carryforward" : true, +"booldependsoff" : "keogramgenerate" +}, +{ +"name" : "keogramfontname", +"default" : "simplex", +"description" : "Font name.", +"label" : "Font Name", +"label_prefix" : "Keograms", +"type" : "select_text", +"options" : [ + {"value" : "simplex", "label" : "Simplex"}, + {"value" : "plain", "label" : "Plain"}, + {"value" : "duplex", "label" : "Duplex"}, + {"value" : "complex", "label" : "Complex"}, + {"value" : "complexsmall", "label" : "Complex Small"}, + {"value" : "triplex", "label" : "Triplex"}, + {"value" : "scriptsimplex", "label" : "Script Simplex"}, + {"value" : "scriptcomplex", "label" : "Script Complex"} +], +"carryforward" : true, +"booldependson" : "keogramgenerate" +}, +{ +"name" : "keogramfontcolor", +"default" : "#fff", +"description" : "Font color.", +"label" : "Font Color", +"label_prefix" : "Keograms", +"type" : "color", +"booldependson" : "keogramgenerate" +}, +{ +"name" : "keogramfontsize", +"minimum" : 1.0, +"maximum" : 10.0, +"default" : 2.0, +"description" : "Font size.", +"label" : "Font Size", +"label_prefix" : "Keograms", +"type" : "integer", +"booldependson" : "keogramgenerate" +}, +{ +"name" : "keogramlinethickness", +"minimum" : 1, +"maximum" : 5, +"default" : 3, +"description" : "Font line thickness.", +"label" : "Font Line Thickness", +"label_prefix" : "Keograms", +"type" : "integer", +"booldependson" : "keogramgenerate" +}, +{ +"name" : "keogramextraparameters", +"default" : "", +"description" : "Optional additional keogram creation parameters.
Run ~/allsky/bin/keogram --help for a list of options.", +"label" : "Extra Parameters", +"label_prefix" : "Keograms", +"type" : "widetext", +"optional" : true, +"booldependson" : "keogramgenerate" +}, + +{ +"name" : "startrailssubheader", +"label" : "Startrails", +"type" : "header-sub" +}, +{ +"name" : "startrailsgenerate", +"default" : true, +"description" : "Enable to generate a startrails at the end of night.", +"label" : "Generate", +"label_prefix" : "Startrails", +"type" : "boolean", +"carryforward" : true, +"booldependson" : "savedaytimeimages OR savenighttimeimages" +}, +{ +"name" : "startrailsbrightnessthreshold", +"minimum" : 0.0, +"maximum" : 1.0, +"default" : 0.1, +"description" : "Images with a brightness higher than this threshold will not be included in the startrails.", +"label" : "Brightness Threshold", +"label_prefix" : "Startrails", +"type" : "float", +"booldependson" : "startrailsgenerate" +}, +{ +"name" : "startrailsupload", +"default" : true, +"description" : "Enable to upload the startrails to an Allsky Website and/or remote server.", +"label" : "Upload", +"label_prefix" : "Startrails", +"type" : "boolean", +"carryforward" : true, +"booldependson" : "startrailsgenerate AND (uselocalwebsite OR useremotewebsite OR useremoteserver)" +}, +{ +"name" : "startrailsextraparameters", +"default" : "", +"description" : "Optional additional startrails creation parameters.
Run ~/allsky/bin/startrails --help for a list of options.", +"label" : "Extra Parameters", +"label_prefix" : "Startrails", +"type" : "widetext", +"optional" : true, +"booldependson" : "startrailsgenerate" +}, + +{ +"name" : "websitetab========================================", +"label" : "Website", +"type" : "header-tab" +}, +{ +"name" : "websiteheader", +"label" : "Websites and Remote Server Settings", +"type" : "header" +}, +{ +"name" : "imageresizeuploadswidth", +"minimum" : 0, +"maximum" : "_max", +"default" : 0, +"description" : "Resized width of uploaded images. 0 means don't resize.", +"label" : "Resize Uploaded Images Width", +"type" : "integer", +"booldependson" : "imageupload" +}, +{ +"name" : "imageresizeuploadsheight", +"minimum" : 0, +"maximum" : "_max", +"default" : 0, +"description" : "Resized height of uploaded images. 0 means don't resize.", +"label" : "Resize Uploaded Images Height", +"type" : "integer", +"booldependson" : "imageupload" +}, +{ +"name" : "imageuploadfrequency", +"minimum" : 0, +"maximum" : "none", +"default" : 1, +"description" : "How often should images be uploaded to an Allsky Website and/or remote server?
0 never uploads images, 1 uploads every frame, 2 every other frame, etc.", +"label" : "Upload Every X Images", +"type" : "integer", +"carryforward" : true, +"booldependson" : "uselocalwebsite OR useremotewebsite OR useremoteserver" +}, +{ +"name" : "displaysettings", +"default" : false, +"description" : "Enable to add a link on the Allsky Website to display the settings on this page.", +"label" : "Display Settings", +"type" : "boolean", +"carryforward" : true, +"checkchanges" : true, +"booldependson" : "uselocalwebsite OR useremotewebsite OR useremoteserver" +}, +{ +"name" : "localwebsiteheader", +"label" : "Local Website", +"type" : "header-sub" +}, +{ +"name" : "uselocalwebsite", +"description" : "Enable to use the local Allsky Website.
You should configure it in the Editor page first.", +"default" : false, +"label" : "Use Local Website", +"type" : "boolean", +"carryforward" : true, +"checkchanges" : true, +"popup-yesno" : "Do you want to remove all saved images, keograms, startrails, and videos in the local Website?", +"popup-yesno-value" : 0 +}, +{ +"name" : "daystokeeplocalwebsite", +"minimum" : 0, +"maximum" : "none", +"default" : 0, +"description" : "Number of days of images and videos to keep on the Pi's Website. 0 keeps all days.", +"label" : "Days To Keep on Pi Website", +"type" : "integer", +"carryforward" : true, +"checkchanges" : true, +"booldependson" : "uselocalwebsite" +}, +{ +"name" : "remotewebsiteheader", +"label" : "Remote Website", +"type" : "header-sub" +}, +{ +"name" : "useremotewebsite", +"default" : false, +"description" : "Enable to use a remote Allsky Website.
See the Allsky Documentation for how to install the Website.", +"label" : "Use Remote Website", +"type" : "boolean", +"carryforward" : true, +"checkchanges" : true +}, +{ +"name" : "daystokeepremotewebsite", +"minimum" : 0, +"maximum" : "none", +"default" : 0, +"description" : "Number of days of images and videos to keep on the remote Website. 0 keeps all days.", +"label" : "Days To Keep on Remote Website", +"type" : "integer", +"carryforward" : true, +"checkchanges" : true, +"booldependson" : "useremotewebsite", +"display" : false +}, +{ +"name" : "remotewebsiteimagedir", +"default" : "allsky", +"description" : "Name of the top-level remote Website directory where images go.", +"label" : "Image Directory", +"label_prefix" : "Remote Website", +"type" : "text", +"carryforward" : true, +"checkchanges" : true, +"booldependson" : "useremotewebsite", +"valuedependson" : "remotewebsiteprotocol=*ftp*", +"optional" : true +}, +{ +"name" : "remotewebsiteprotocol", +"default" : "ftps", +"description" : "Protocol for remote Website", +"label" : "Protocol", +"label_prefix" : "Remote Website", +"type" : "select_text", +"options" : [ + {"value" : "ftps", "label" : "ftps"}, + {"value" : "ftp", "label" : "ftp"}, + {"value" : "sftp", "label" : "sftp"}, + {"value" : "scp", "label" : "scp"}, + {"value" : "rsync", "label" : "rsync"}, + {"value" : "s3", "label" : "S3"}, + {"value" : "gcs", "label" : "GCS"} +], +"carryforward" : true, +"checkchanges" : true, +"booldependson" : "useremotewebsite" +}, +{ +"name" : "REMOTEWEBSITE_HOST", +"default" : "", +"description" : "Name of server hosting the remote Allsky Website.", +"label" : "Server Name", +"label_prefix" : "Remote Website", +"type" : "widetext", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremotewebsite", +"optional" : true +}, +{ +"name" : "REMOTEWEBSITE_PORT", +"default" : "", +"description" : "Optional port required by server. Rarely required.", +"label" : "Port", +"label_prefix" : "Remote Website", +"type" : "text", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremotewebsite", +"optional" : true +}, +{ +"name" : "REMOTEWEBSITE_USER", +"default" : "", +"description" : "Username of the login on the remote Website server.", +"label" : "User Name", +"label_prefix" : "Remote Website", +"type" : "text", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremotewebsite", +"optional" : true +}, +{ +"name" : "REMOTEWEBSITE_PASSWORD", +"default" : "", +"description" : "Password of the login on the remote Website server.", +"label" : "Password", +"label_prefix" : "Remote Website", +"type" : "password", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremotewebsite", +"optional" : true +}, +{ +"name" : "REMOTEWEBSITE_LFTP_COMMANDS", +"default" : "", +"description" : "Special commands needed when connecting to an FTP server.", +"label" : "FTP Commands", +"label_prefix" : "Remote Website", +"type" : "text", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremotewebsite", +"valuedependson" : "remotewebsiteprotocol=*ftp*", +"optional" : true +}, +{ +"name" : "REMOTEWEBSITE_SSH_KEY_FILE", +"default" : "", +"description" : "Path on the Pi to the SSH key file.", +"label" : "SSH Key File", +"label_prefix" : "Remote Website", +"type" : "widetext", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremotewebsite", +"valuedependson" : "remotewebsiteprotocol=scp|rsync", +"optional" : true +}, +{ +"name" : "REMOTEWEBSITE_AWS_CLI_DIR", +"default" : "", +"description" : "AWS CLI directory where the AWS CLI tools are installed.
Often stored in ${HOME}/.local/bin", +"label" : "AWS CLI Directory", +"label_prefix" : "Remote Website", +"type" : "widetext", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremotewebsite", +"valuedependson" : "remotewebsiteprotocol=s3", +"optional" : true +}, +{ +"name" : "REMOTEWEBSITE_S3_BUCKET", +"default" : "allskybucket", +"description" : "Name of S3 bucket where files will be upload.", +"label" : "S3 Bucket", +"label_prefix" : "Remote Website", +"type" : "text", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremotewebsite", +"valuedependson" : "remotewebsiteprotocol=s3", +"optional" : true +}, +{ +"name" : "REMOTEWEBSITE_S3_ACL", +"default" : "private", +"description" : "S3 Access Control List (ACL).", +"label" : "S3 ACL", +"label_prefix" : "Remote Website", +"type" : "text", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremotewebsite", +"valuedependson" : "remotewebsiteprotocol=s3", +"optional" : true +}, +{ +"name" : "REMOTEWEBSITE_GCS_BUCKET", +"default" : "allskybucket", +"description" : "Name of GCS bucket where files will be upload.", +"label" : "GCS Bucket", +"label_prefix" : "Remote Website", +"type" : "text", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremotewebsite", +"valuedependson" : "remotewebsiteprotocol=gcs", +"optional" : true +}, +{ +"name" : "REMOTEWEBSITE_GCS_ACL", +"default" : "private", +"description" : "GCS Access Control List (ACL).", +"label" : "GCS ACL", +"label_prefix" : "Remote Website", +"type" : "text", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremotewebsite", +"valuedependson" : "remotewebsiteprotocol=gcs", +"optional" : true +}, +{ +"name" : "remotewebsiteimageuploadoriginalname", +"default" : false, +"description" : "Enable to upload images using YYYYMMDDHHMMSS naming.", +"label" : "Upload With Original Name", +"label_prefix" : "Remote Website", +"type" : "boolean", +"carryforward" : true, +"valuedependson" : "imageuploadfrequency=[1-9]*" +}, +{ +"name" : "remotewebsitevideodestinationname", +"default" : "", +"description" : "Optional name of the remote video file.", +"label" : "Remote Video File Name", +"label_prefix" : "Remote Website", +"type" : "text", +"carryforward" : true, +"booldependson" : "useremotewebsite", +"optional" : true +}, +{ +"name" : "remotewebsitekeogramdestinationname", +"default" : "", +"description" : "Optional name of the remote keogram file.", +"label" : "Remote Keogram File Name", +"label_prefix" : "Remote Website", +"type" : "text", +"carryforward" : true, +"booldependson" : "useremotewebsite", +"optional" : true +}, +{ +"name" : "remotewebsitestartrailsdestinationname", +"default" : "", +"description" : "Optional name of the remote startrails file.", +"label" : "Remote Startrails File Name", +"label_prefix" : "Remote Website", +"type" : "text", +"carryforward" : true, +"booldependson" : "useremotewebsite", +"optional" : true +}, +{ +"name" : "remotewebsiteurl", +"default" : "", +"description" : "The URL of your Allsky Website, for example: https://mywebsite.com/allsky/.
Must begin with http or https.", +"label" : "Website URL", +"label_prefix" : "Remote Website", +"type" : "widetext", +"carryforward" : true, +"checkchanges" : true, +"booldependson" : "showonmap OR useremotewebsite OR remotewebsiteimageurl", +"optional" : true +}, +{ +"name" : "remotewebsiteimageurl", +"default" : "", +"description" : "The URL of the image on your Allsky Website, for example: https://mywebsite.com/allsky/image.jpg.
Must begin with http or https.", +"label" : "Image URL", +"label_prefix" : "Remote Website", +"type" : "widetext", +"carryforward" : true, +"checkchanges" : true, +"booldependson" : "showonmap OR useremotewebsite OR remotewebsiteurl", +"optional" : true +}, + + +{ +"name" : "remoteserverheader", +"label" : "Remote Server", +"type" : "header-sub" +}, +{ +"name" : "useremoteserver", +"default" : false, +"description" : "This is a remote server NOT running the Allsky Website.
See the documentation for the necessary directory layout.", +"label" : "Use Remote Server", +"type" : "boolean", +"carryforward" : true, +"checkchanges" : true +}, +{ +"name" : "remoteserverimagedir", +"default" : "", +"description" : "Name of the top-level remote server directory where images go.", +"label" : "Image Directory", +"label_prefix" : "Remote Server", +"type" : "text", +"carryforward" : true, +"checkchanges" : true, +"booldependson" : "useremoteserver", +"optional" : true +}, +{ +"name" : "remoteserverprotocol", +"default" : "ftps", +"description" : "Protocol for remote server", +"label" : "Protocol", +"label_prefix" : "Remote Server", +"type" : "select_text", +"options" : [ + {"value" : "ftps", "label" : "ftps"}, + {"value" : "ftp", "label" : "ftp"}, + {"value" : "sftp", "label" : "sftp"}, + {"value" : "scp", "label" : "scp"}, + {"value" : "rsync", "label" : "rsync"}, + {"value" : "s3", "label" : "S3"}, + {"value" : "gcs", "label" : "GCS"} +], +"carryforward" : true, +"checkchanges" : true, +"booldependson" : "useremoteserver" +}, +{ +"name" : "REMOTESERVER_HOST", +"default" : "", +"description" : "Name of remote server NOT hosting an Allsky Website.", +"label" : "Server Name", +"label_prefix" : "Remote Server", +"type" : "widetext", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremoteserver", +"optional" : true +}, +{ +"name" : "REMOTESERVER_PORT", +"default" : "", +"description" : "Optional port required by remote server. Rarely required.", +"label" : "Port", +"label_prefix" : "Remote Server", +"type" : "text", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremoteserver", +"optional" : true +}, +{ +"name" : "REMOTESERVER_USER", +"default" : "", +"description" : "Username of the login on the remote server.", +"label" : "User Name", +"label_prefix" : "Remote Server", +"type" : "text", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremoteserver", +"optional" : true +}, +{ +"name" : "REMOTESERVER_PASSWORD", +"default" : "", +"description" : "Password of the remote server login.", +"label" : "Password", +"label_prefix" : "Remote Server", +"type" : "password", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremoteserver", +"optional" : true +}, +{ +"name" : "REMOTESERVER_LFTP_COMMANDS", +"default" : "", +"description" : "Special commands needed when connecting to an FTP server.", +"label" : "FTP Commands", +"label_prefix" : "Remote Server", +"type" : "text", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremoteserver", +"valuedependson" : "remoteserverprotocol=*ftp*", +"optional" : true +}, +{ +"name" : "REMOTESERVER_SSH_KEY_FILE", +"default" : "", +"description" : "Path on the Pi to the SSH key file.", +"label" : "SSH Key File", +"label_prefix" : "Remote Server", +"type" : "widetext", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremoteserver", +"valuedependson" : "remoteserverprotocol=scp|rsync", +"optional" : true +}, +{ +"name" : "REMOTESERVER_AWS_CLI_DIR", +"default" : "", +"description" : "AWS CLI directory where the AWS CLI tools are installed.
Often stored in ${HOME}/.local/bin", +"label" : "AWS CLI Directory", +"label_prefix" : "Remote Server", +"type" : "widetext", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremoteserver", +"valuedependson" : "remoteserverprotocol=s3", +"optional" : true +}, +{ +"name" : "REMOTESERVER_S3_BUCKET", +"default" : "allskybucket", +"description" : "Name of S3 bucket where files will be upload.", +"label" : "S3 Bucket", +"label_prefix" : "Remote Server", +"type" : "text", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremoteserver", +"valuedependson" : "remoteserverprotocol=s3", +"optional" : true +}, +{ +"name" : "REMOTESERVER_S3_ACL", +"default" : "private", +"description" : "S3 Access Control List (ACL).", +"label" : "S3 ACL", +"label_prefix" : "Remote Server", +"type" : "text", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremoteserver", +"valuedependson" : "remoteserverprotocol=s3", +"optional" : true +}, +{ +"name" : "REMOTESERVER_GCS_BUCKET", +"default" : "allskybucket", +"description" : "Name of GCS bucket where files will be upload.", +"label" : "GCS Bucket", +"label_prefix" : "Remote Server", +"type" : "text", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremoteserver", +"valuedependson" : "remoteserverprotocol=gcs", +"optional" : true +}, +{ +"name" : "REMOTESERVER_GCS_ACL", +"default" : "private", +"description" : "GCS Access Control List (ACL).", +"label" : "GCS ACL", +"label_prefix" : "Remote Server", +"type" : "text", +"checkchanges" : true, +"source" : "${ALLSKY_ENV}", +"booldependson" : "useremoteserver", +"valuedependson" : "remoteserverprotocol=gcs", +"optional" : true +}, +{ +"name" : "remoteserverimageuploadoriginalname", +"default" : false, +"description" : "Enable to upload images using YYYYMMDDHHMMSS naming.", +"label" : "Upload With Original Name", +"label_prefix" : "Remote Server", +"type" : "boolean", +"carryforward" : true, +"valuedependson" : "imageuploadfrequency=[1-9]*" +}, +{ +"name" : "remoteservervideodestinationname", +"default" : "", +"description" : "Optional name of the remote video file.", +"label" : "Remote Video File Name", +"label_prefix" : "Remote Server", +"type" : "text", +"carryforward" : true, +"booldependson" : "useremoteserver", +"optional" : true +}, +{ +"name" : "remoteserverkeogramdestinationname", +"default" : "", +"description" : "Optional name of the remote keogram file.", +"label" : "Remote Keogram File Name", +"label_prefix" : "Remote Server", +"type" : "text", +"carryforward" : true, +"booldependson" : "useremoteserver", +"optional" : true +}, +{ +"name" : "remoteserverstartrailsdestinationname", +"default" : "", +"description" : "Optional name of the remote startrails file.", +"label" : "Remote Startrails File Name", +"label_prefix" : "Remote Server", +"type" : "text", +"carryforward" : true, +"booldependson" : "useremoteserver", +"optional" : true +}, + +{ +"name" : "allskymaptab========================================", +"label" : "Map", +"type" : "header-tab" +}, +{ +"name" : "allskymapheader", +"label" : "Allsky Map Settings", +"type" : "header" +}, +{ +"name" : "showonmap", +"default" : false, +"description" : "Enable to have your camera appear on the Allsky Map .", +"label" : "Show on Map", +"type" : "boolean", +"carryforward" : true, +"checkchanges" : true, +"booldependson" : "uselocalwebsite OR useremotewebsite" +}, +{ +"name" : "location", +"default" : "", +"description" : "The location of your camera, for example: Whitehorse, YT.
This setting and the remaining ones also appear on the Allsky Website, if installed.", +"label" : "Location", +"type" : "widetext", +"carryforward" : true, +"checkchanges" : true, +"optional" : true, +"booldependson" : "showonmap OR useremotewebsite OR useremoteserver" +}, +{ +"name" : "owner", +"default" : "", +"description" : "The owner of the camera. It can be your name, an association, or observatory, etc.", +"label" : "Owner", +"type" : "text", +"carryforward" : true, +"checkchanges" : true, +"optional" : true, +"booldependson" : "showonmap OR useremotewebsite OR useremoteserver" +}, +{ +"name" : "camera", +"default" : "", +"description" : "The type and model of your camera, for example: ZWO 224MC or RPi HQ. Set automatically.", +"label" : "Camera", +"type" : "widetext", +"checkchanges" : true, +"optional" : true, +"booldependson" : "showonmap OR useremotewebsite OR useremoteserver" +}, +{ +"name" : "lens", +"default" : "", +"description" : "The lens you're using on your camera, for example: Arecont 1.55.", +"label" : "Lens", +"type" : "text", +"checkchanges" : true, +"optional" : true, +"booldependson" : "showonmap OR useremotewebsite OR useremoteserver" +}, +{ +"name" : "computer", +"default" : "", +"description" : "The computer running your allsky camera, for example: Raspberry Pi 3, 4 GB. Set automatically.", +"label" : "Computer", +"type" : "widetext", +"carryforward" : true, +"checkchanges" : true, +"optional" : true, +"booldependson" : "showonmap OR useremotewebsite OR useremoteserver" +}, + +{ +"name" : "webuitab========================================", +"label" : "WebUI", +"type" : "header-tab" +}, +{ +"name" : "webuiheading", +"label" : "WebUI Configuration", +"type" : "header" +}, +{ +"name" : "imagessortorder", +"default" : "ascending", +"description" : "The order the images on the 'Images' page are sorted in.
'Ascending' is oldest to newest. 'Descending' is newest to oldest.", +"label" : "Images Sort Order", +"type" : "select_text", +"carryforward" : true, +"options" : [ + {"value" : "ascending", "label" : "Ascending"}, + {"value" : "descending", "label" : "Descending"} +] +}, +{ +"name" : "showupdatedmessage", +"default" : true, +"description" : "Disable to hide the Daytime images updated every... message in the Live View page.", +"label" : "Show Updated Message", +"type" : "boolean", +"carryforward" : true +}, +{ +"name" : "uselogin", +"default" : true, +"description" : "Determines if you need to login to the WebUI or not.
If your Pi is accessible on the Internet, do NOT turn this off!!.", +"label" : "Require WebUI Login", +"type" : "boolean", +"checkchanges" : true, +"carryforward" : true +}, +{ +"name" : "notificationimages", +"default" : true, +"description" : "Enable to display notification images, e.g., 'Camera is off during the day'.
While these messages appear in the WebUI and Allsky Website, they are not saved so don't appear in timelapse, startrails, or keograms.", +"label" : "Notification Images", +"type" : "boolean", +"usage" : "capture", +"carryforward" : true, +"action" : "reload" +}, +{ +"name" : "webuidatafiles", +"default" : "", +"description" : "See the documentation for a description of this setting.", +"label" : "System Page Additions", +"type" : "widetext", +"carryforward" : true, +"optional" : true +}, +{ +"name" : "daytimeoverlay", +"settingsonly" : true, +"default" : "", +"description" : "The overlay file to use during daytime", +"label" : "NOT DISPLAYED IN WebUI", +"type" : "text" +}, +{ +"name" : "nighttimeoverlay", +"settingsonly" : true, +"default" : "", +"description" : "The overlay file to use during nighttime", +"label" : "NOT DISPLAYED IN WebUI", +"type" : "text" +}, + +{ + +"name" : "cameratab========================================", +"label" : "Camera Type", +"type" : "header-tab" +}, +{ +"name" : "cameraheading", +"label" : "Camera", +"type" : "header" +}, +{ +"name" : "cameratype", +"default" : "", +"description" : "The type of camera you are using.
To change to a camera of a different type, select the new type then press the 'Save changes' button, THEN select the new Camera Model.", +"label" : "Camera Type", +"type" : "select_text", +"options" : [ + "cameratype_values" +], +"checkchanges" : true, +"action" : "restart" +}, +{ +"name" : "cameramodel", +"default" : "", +"description" : "The model of camera you are using.", +"label" : "Camera Model", +"readonly" : true, +"type" : "select_text", +"options" : [ + "cameramodel_values" +], +"usage" : "capture", +"checkchanges" : true, +"action" : "restart" +}, +{ +"name" : "cameranumber", +"settingsonly" : true, "default" : 0, "description" : "If multiple cameras are connected to the Pi, this is the camera number (starts at 0).", "label" : "Camera Number", "type" : "integer", -"checkchanges" : 1, -"displayxxx" : "_display", -"display" : 0 +"usage" : "capture", +"checkchanges" : true, +"action" : "restart" }, + { -"name" : "XX_END_XX" +"name" : "XX_END_XX", +"type" : "boolean" } ] diff --git a/config_repo/overlay/config/fields.json b/config_repo/overlay/config/fields.json index ee7f1c315..61aa6e350 100755 --- a/config_repo/overlay/config/fields.json +++ b/config_repo/overlay/config/fields.json @@ -5,7 +5,7 @@ "name": "${DATE}", "description": "Date Image Taken", "format": "%d-%m-%Y", - "sample": "DATE", + "sample": "10-07-2024", "type": "Date", "source": "System" }, @@ -14,7 +14,7 @@ "name": "${TIME}", "description": "Time Image Taken", "format": "%H:%M:%S", - "sample": "TIME", + "sample": "21:04:05", "type": "Time", "source": "System" }, @@ -48,7 +48,7 @@ { "id": 5, "name": "${EXPOSURE_US}", - "description": "Frame Exposure Duration", + "description": "Frame Exposure Duration in us", "format": "{:n}", "sample": "10000", "type": "Number", @@ -66,7 +66,7 @@ { "id": 7, "name": "${sEXPOSURE}", - "description": "Frame Exposure Auto String", + "description": "Frame Exposure String", "format": "", "sample": "32.00 ms (0.03 sec)", "type": "Text", @@ -85,8 +85,8 @@ "id": 9, "name": "${GAIN}", "description": "Camera Gain Amount", - "format": "", - "sample": "180", + "format": "{:.2f}", + "sample": "120.12", "type": "Number", "source": "System" }, @@ -101,24 +101,15 @@ }, { "id": 11, - "name": "${BRIGHTNESS}", - "description": "Brightness Amount", - "format": "", - "sample": "50", - "type": "Number", - "source": "System" - }, - { - "id": 12, "name": "${MEAN}", "description": "Mean Brightness Amount", - "format": "", + "format": "{:.2f}", "sample": "", "type": "Number", "source": "System" }, { - "id": 13, + "id": 12, "name": "${AUTOWB}", "description": "Auto White Balance Flag", "format": "", @@ -127,7 +118,7 @@ "source": "System" }, { - "id": 14, + "id": 13, "name": "${sAUTOAWB}", "description": "Auto White Balance Auto String", "format": "", @@ -136,25 +127,25 @@ "source": "System" }, { - "id": 15, + "id": 14, "name": "${WBR}", "description": "White Balance RED Amount", - "format": "", + "format": "{:.2f}", "sample": "3.47", "type": "Number", "source": "System" }, { - "id": 16, + "id": 15, "name": "${WBB}", "description": "White Balance Blue Amount", - "format": "", + "format": "{:.2f}", "sample": "1.00", "type": "Number", "source": "System" }, { - "id": 17, + "id": 16, "name": "${FLIP}", "description": "Flip", "format": "", @@ -163,34 +154,34 @@ "source": "System" }, { - "id": 18, + "id": 17, "name": "${BIN}", "description": "Binning", - "format": "", + "format": "{:n}", "sample": "1", "type": "Number", "source": "System" }, { - "id": 19, + "id": 18, "name": "${BIT_DEPTH}", "description": "Bit Depth", - "format": "", + "format": "{:n}", "sample": "12", "type": "Number", "source": "System" }, { - "id": 20, + "id": 19, "name": "${FOCUS}", "description": "Focus Metric", - "format": "", + "format": "{:n}", "sample": "", "type": "Number", "source": "System" }, { - "id": 21, + "id": 20, "name": "${25544ALT}", "description": "ISS Altitude", "format": "", @@ -199,7 +190,7 @@ "source": "System" }, { - "id": 22, + "id": 21, "name": "${25544AZ}", "description": "ISS Azimuth", "format": "", @@ -208,7 +199,7 @@ "source": "System" }, { - "id": 23, + "id": 22, "name": "${25544VISIBLE}", "description": "ISS Visible Flag", "format": "", @@ -217,7 +208,7 @@ "source": "System" }, { - "id": 24, + "id": 23, "name": "${MOON_ELEVATION}", "description": "Moon Altitude", "format": "", @@ -226,7 +217,7 @@ "source": "System" }, { - "id": 25, + "id": 24, "name": "${MOON_AZIMUTH}", "description": "Moon Azimuth", "format": "", @@ -235,7 +226,7 @@ "source": "System" }, { - "id": 26, + "id": 25, "name": "${MOON_ILLUMINATION}", "description": "Moon Illumination Percent", "format": "{:.1f}", @@ -244,61 +235,61 @@ "source": "System" }, { - "id": 27, + "id": 26, "name": "${MOON_SYMBOL}", - "description": "Moon Font Symbol", + "description": "Moon Font Symbol - must use moon_phases font", "format": "", "sample": "T", "type": "Text", "source": "System" }, { - "id": 28, + "id": 27, "name": "${SUN_DAWN}", "description": "Dawn", "format": "%d-%m-%Y %H:%M:%S", - "sample": "2023-07-10 05:38:27", + "sample": "10-07-2024 05:38:27", "type": "Date", "source": "System" }, { - "id": 29, + "id": 28, "name": "${SUN_SUNRISE}", "description": "Sunrise", "format": "%d-%m-%Y %H:%M:%S", - "sample": "2023-07-10 06:06:11", + "sample": "10-07-2024 06:06:11", "type": "Date", "source": "System" }, { - "id": 30, + "id": 29, "name": "${SUN_NOON}", "description": "Noon", "format": "%d-%m-%Y %H:%M:%S", - "sample": "2023-07-10 12:02:50", + "sample": "10-07-2024 12:02:50", "type": "Date", "source": "System" }, { - "id": 31, + "id": 30, "name": "${SUN_SUNSET}", "description": "Sunset", "format": "%d-%m-%Y %H:%M:%S", - "sample": "2023-07-10 18:59:28", + "sample": "10-07-2024 18:59:28", "type": "Date", "source": "System" }, { - "id": 32, + "id": 31, "name": "${SUN_DUSK}", "description": "Dusk", "format": "%d-%m-%Y %H:%M:%S", - "sample": "2023-07-11 18:27:12", + "sample": "10-07-2024 18:27:12", "type": "Date", "source": "System" }, { - "id": 33, + "id": 32, "name": "${SUN_AZIMUTH}", "description": "Sun Azimuth", "format": "{:.1f}", @@ -307,7 +298,7 @@ "source": "System" }, { - "id": 34, + "id": 33, "name": "${SUN_ELEVATION}", "description": "Sun Elevation", "format": "{:.1f}", @@ -316,7 +307,7 @@ "source": "System" }, { - "id": 35, + "id": 34, "name": "${CAMERA_TYPE}", "description": "Camera Type", "format": "", @@ -325,7 +316,7 @@ "source": "System" }, { - "id": 36, + "id": 35, "name": "${CAMERA_MODEL}", "description": "Camera Model", "format": "", @@ -334,7 +325,7 @@ "source": "System" }, { - "id": 37, + "id": 36, "name": "${AUTOUSB}", "description": "AUTOUSB Flag (ZWO Only)", "format": "", @@ -343,16 +334,16 @@ "source": "System" }, { - "id": 38, + "id": 37, "name": "${USB}", "description": "USB Bandwidth (ZWO Only)", - "format": "", + "format": "{:n}", "sample": "55", "type": "Number", "source": "System" }, { - "id": 39, + "id": 38, "name": "${DARKFRAME}", "description": "Taking Dark Frames Flag", "format": "", @@ -361,16 +352,16 @@ "source": "System" }, { - "id": 40, + "id": 39, "name": "${eOVERLAY}", - "description": "External Overlay", - "format": "", + "description": "Overlay Method number", + "format": "{:n}", "sample": "1", "type": "Number", "source": "System" }, { - "id": 41, + "id": 40, "name": "${DAY_OR_NIGHT}", "description": "Day\/Night string", "format": "", @@ -379,11 +370,11 @@ "source": "System" }, { - "id": 42, + "id": 41, "name": "${ALLSKY_VERSION}", "description": "Allsky version string", "format": "", - "sample": "v2023.03.09_tbd", + "sample": "v2024.11.15", "type": "Text", "source": "System" } diff --git a/config_repo/overlay/config/overlay-RPi.json b/config_repo/overlay/config/overlay-RPi.json index 867b1bb4c..136609823 100644 --- a/config_repo/overlay/config/overlay-RPi.json +++ b/config_repo/overlay/config/overlay-RPi.json @@ -1,51 +1,49 @@ { "fields": [ { - "label": "${DATE} ${TIME}", - "x": 330, - "y": 50, + "label": "${DATE} ${TIME}", + "x": 260, + "y": 46, "id": "oe-field-0", - "format": "%d-%m-%Y,%H:%M:%S", + "format": "{%d-%m-%Y}{%H:%M:%S}", "strokewidth": 0, "sample": "DATE,TIME", - "tlx": 10, - "tly": 10, - "font": "Arial", - "fontsize": 80, - "fill": "#ff0000" + "tlx": 20, + "tly": 16, + "fill": "#ff0000", + "fontsize": 60 }, { "label": "Exposure: ${sEXPOSURE}", - "x": 421, - "y": 124, + "x": 309, + "y": 98, "id": "oe-field-1", - "font": "Arial", - "fontsize": 68, + "fontsize": 48, "strokewidth": 0, - "tlx": 10, - "tly": 90 + "tlx": 20, + "tly": 74 }, { "label": "Gain: ${GAIN}", - "x": 226, - "y": 194, + "x": 172, + "y": 158, "id": "oe-field-2", - "font": "Arial", - "fontsize": 68, + "format": "{:.2f}", + "fontsize": 48, "strokewidth": 0, - "tlx": 10, - "tly": 160 + "tlx": 20, + "tly": 134 }, { - "label": "${CAMERA_TYPE} ${CAMERA_MODEL}", - "x": 419, - "y": 3002, + "label": "${CAMERA_TYPE} ${CAMERA_MODEL}", + "x": 396, + "y": 214, "id": "oe-field-3", - "fontsize": 44, + "fontsize": 40, "strokewidth": 0, - "sample": "RPi HQ", - "tlx": 10, - "tly": 2980 + "tlx": 20, + "tly": 194, + "sample": "RPi some_model" } ], "images": [], @@ -71,5 +69,13 @@ "backgroundcolor": "white", "fontopacity": 0.5 } + }, + "metadata": { + "camerabrand": "RPi", + "cameramodel": "HQ", + "cameraresolutionwidth": "4056", + "cameraresolutionheight": "3040", + "tod": "both", + "name": "RPI generic" } } diff --git a/config_repo/overlay/config/overlay-RPi_HQ-4056x3040-both.json b/config_repo/overlay/config/overlay-RPi_HQ-4056x3040-both.json new file mode 100644 index 000000000..4fee37f2b --- /dev/null +++ b/config_repo/overlay/config/overlay-RPi_HQ-4056x3040-both.json @@ -0,0 +1,81 @@ +{ + "fields": [ + { + "label": "${DATE} ${TIME}", + "x": 260, + "y": 46, + "id": "oe-field-0", + "format": "{%d-%m-%Y}{%H:%M:%S}", + "strokewidth": 0, + "sample": "DATE,TIME", + "tlx": 20, + "tly": 16, + "fill": "#ff0000", + "fontsize": 60 + }, + { + "label": "Exposure: ${sEXPOSURE}", + "x": 309, + "y": 98, + "id": "oe-field-1", + "fontsize": 48, + "strokewidth": 0, + "tlx": 20, + "tly": 74 + }, + { + "label": "Gain: ${GAIN}", + "x": 172, + "y": 158, + "id": "oe-field-2", + "format": "{:.2f}", + "fontsize": 48, + "strokewidth": 0, + "tlx": 20, + "tly": 134 + }, + { + "label": "${CAMERA_TYPE} ${CAMERA_MODEL}", + "x": 396, + "y": 214, + "id": "oe-field-3", + "fontsize": 40, + "strokewidth": 0, + "tlx": 20, + "tly": 194, + "sample": "RPi some_model" + } + ], + "images": [], + "settings": { + "defaultdatafileexpiry": "550", + "defaultincludeplanets": false, + "defaultincludesun": false, + "defaultincludemoon": false, + "defaultimagetopacity": 0.63, + "defaultimagerotation": 0, + "defaulttextrotation": 0, + "defaultfontopacity": 1, + "defaultfontcolour": "white", + "defaultfont": "Arial", + "defaultfontsize": 52, + "defaultimagescale": 1, + "defaultnoradids": "" + }, + "fonts": { + "moon_phases": { + "fontPath": "fonts\/moon_phases.ttf", + "fonttcolour": "red", + "backgroundcolor": "white", + "fontopacity": 0.5 + } + }, + "metadata": { + "camerabrand": "RPi", + "cameramodel": "HQ", + "cameraresolutionwidth": "4056", + "cameraresolutionheight": "3040", + "tod": "both", + "name": "RPI HQ" + } +} diff --git a/config_repo/overlay/config/overlay-RPi_Module_3-4608x2592-both.json b/config_repo/overlay/config/overlay-RPi_Module_3-4608x2592-both.json new file mode 100644 index 000000000..7a0f295a9 --- /dev/null +++ b/config_repo/overlay/config/overlay-RPi_Module_3-4608x2592-both.json @@ -0,0 +1,81 @@ +{ + "fields": [ + { + "label": "${DATE} ${TIME}", + "x": 211, + "y": 44, + "id": "oe-field-0", + "format": "{%d-%m-%Y}{%H:%M:%S}", + "strokewidth": 0, + "sample": "DATE,TIME", + "tlx": 19, + "tly": 20, + "fontsize": 48, + "fill": "#ff0000" + }, + { + "label": "Exposure: ${sEXPOSURE}", + "x": 237, + "y": 98, + "id": "oe-field-1", + "fontsize": 36, + "strokewidth": 0, + "tlx": 19, + "tly": 80 + }, + { + "label": "Gain: ${GAIN}", + "x": 134, + "y": 158, + "id": "oe-field-2", + "format": "{:.2f}", + "fontsize": 36, + "strokewidth": 0, + "tlx": 19, + "tly": 140 + }, + { + "label": "${CAMERA_TYPE} ${CAMERA_MODEL}", + "x": 279, + "y": 214, + "id": "oe-field-3", + "fontsize": 28, + "strokewidth": 0, + "tlx": 19, + "tly": 200, + "sample": "RPi some_model" + } + ], + "images": [], + "settings": { + "defaultdatafileexpiry": "550", + "defaultincludeplanets": false, + "defaultincludesun": false, + "defaultincludemoon": false, + "defaultimagetopacity": 0.63, + "defaultimagerotation": 0, + "defaulttextrotation": 0, + "defaultfontopacity": 1, + "defaultfontcolour": "white", + "defaultfont": "Arial", + "defaultfontsize": 52, + "defaultimagescale": 1, + "defaultnoradids": "" + }, + "fonts": { + "moon_phases": { + "fontPath": "fonts\/moon_phases.ttf", + "fonttcolour": "red", + "backgroundcolor": "white", + "fontopacity": 0.5 + } + }, + "metadata": { + "camerabrand": "RPi", + "cameramodel": "Module 3", + "cameraresolutionwidth": "4608", + "cameraresolutionheight": "2592", + "tod": "both", + "name": "RPI Module 3" + } +} diff --git a/config_repo/overlay/config/overlay.json b/config_repo/overlay/config/overlay-RPi_Version_1-2592x1944-both.json similarity index 67% rename from config_repo/overlay/config/overlay.json rename to config_repo/overlay/config/overlay-RPi_Version_1-2592x1944-both.json index bc8f77250..3f787a37d 100644 --- a/config_repo/overlay/config/overlay.json +++ b/config_repo/overlay/config/overlay-RPi_Version_1-2592x1944-both.json @@ -2,47 +2,48 @@ "fields": [ { "label": "${DATE} ${TIME}", - "x": 121, + "x": 122, "y": 24, "id": "oe-field-0", - "format": "%d-%m-%Y,%H:%M:%S", + "format": "{%d-%m-%Y}{%H:%M:%S}", "strokewidth": 0, "sample": "DATE,TIME", - "tlx": 9, + "tlx": 10, "tly": 10, "fontsize": 28, "fill": "#ff0000" }, { "label": "Exposure: ${sEXPOSURE}", - "x": 178, - "y": 54, + "x": 131, + "y": 50, "id": "oe-field-1", - "fontsize": 28, + "fontsize": 20, "strokewidth": 0, - "tlx": 9, + "tlx": 10, "tly": 40 }, { "label": "Gain: ${GAIN}", - "x": 98, - "y": 84, + "x": 73, + "y": 75, "id": "oe-field-2", - "fontsize": 28, + "format": "{:.2f}", + "fontsize": 20, "strokewidth": 0, - "tlx": 9, - "tly": 70 + "tlx": 10, + "tly": 65 }, { "label": "${CAMERA_TYPE} ${CAMERA_MODEL}", - "x": 158, - "y": 1078, + "x": 157, + "y": 100, "id": "oe-field-3", "fontsize": 16, "strokewidth": 0, - "sample": "ZWO ASI174MC", - "tlx": 9, - "tly": 1070 + "tlx": 10, + "tly": 90, + "sample": "RPi some_model" } ], "images": [], @@ -68,5 +69,14 @@ "backgroundcolor": "white", "fontopacity": 0.5 } - } + }, + "metadata": { + "camerabrand": "RPi", + "cameramodel": "Module 1", + "cameraresolutionwidth": "2592", + "cameraresolutionheight": "1944", + "tod": "both", + "name": "RPI Module 1" + } } + diff --git a/config_repo/overlay/config/overlay-ZWO.json b/config_repo/overlay/config/overlay-ZWO.json index 9e1c3817b..1ff99ac66 100644 --- a/config_repo/overlay/config/overlay-ZWO.json +++ b/config_repo/overlay/config/overlay-ZWO.json @@ -2,59 +2,60 @@ "fields": [ { "label": "${DATE} ${TIME}", - "x": 121, + "x": 132, "y": 24, "id": "oe-field-0", - "format": "%d-%m-%Y,%H:%M:%S", + "format": "{%d-%m-%Y}{%H:%M:%S}", "strokewidth": 0, "sample": "DATE,TIME", - "tlx": 9, + "tlx": 19, "tly": 10, "fontsize": 28, "fill": "#ff0000" }, { "label": "Exposure: ${sEXPOSURE}", - "x": 178, - "y": 54, + "x": 141, + "y": 50, "id": "oe-field-1", - "fontsize": 28, + "fontsize": 20, "strokewidth": 0, - "tlx": 9, + "tlx": 19, "tly": 40 }, { "label": "Gain: ${GAIN}", - "x": 98, - "y": 84, + "x": 83, + "y": 75, "id": "oe-field-2", - "fontsize": 28, + "format": "{:n}", + "fontsize": 20, "strokewidth": 0, - "tlx": 9, - "tly": 70 + "tlx": 19, + "tly": 65 }, { "label": "Sensor: ${TEMPERATURE_C} C", - "x": 215, - "y": 114, + "x": 167, + "y": 100, "id": "oe-field-3", "format": "{:.1f}", - "fontsize": 28, + "fontsize": 20, "strokewidth": 0, - "tlx": 9, - "tly": 100, + "tlx": 19, + "tly": 90, "sample": "20.1" }, { "label": "${CAMERA_TYPE} ${CAMERA_MODEL}", - "x": 195, - "y": 1080, + "x": 169, + "y": 128, "id": "oe-field-4", - "fontsize": 20, + "fontsize": 16, "strokewidth": 0, - "sample": "ZWO ASI174MC", - "tlx": 9, - "tly": 1070 + "sample": "ZWO some_model", + "tlx": 19, + "tly": 120 } ], "images": [], @@ -80,5 +81,13 @@ "backgroundcolor": "white", "fontopacity": 0.5 } + }, + "metadata": { + "camerabrand": "ZWO", + "cameramodel": "generic", + "cameraresolutionwidth": "1920", + "cameraresolutionheight": "960", + "tod": "both", + "name": "ZWO" } } diff --git a/config_repo/overlay/config/overlay-ZWO_ASI120MC-S-1280x960-both.json b/config_repo/overlay/config/overlay-ZWO_ASI120MC-S-1280x960-both.json new file mode 100644 index 000000000..e55902683 --- /dev/null +++ b/config_repo/overlay/config/overlay-ZWO_ASI120MC-S-1280x960-both.json @@ -0,0 +1,93 @@ +{ + "fields": [ + { + "label": "${DATE} ${TIME}", + "x": 132, + "y": 24, + "id": "oe-field-0", + "format": "{%d-%m-%Y}{%H:%M:%S}", + "strokewidth": 0, + "sample": "DATE,TIME", + "tlx": 19, + "tly": 10, + "fontsize": 28, + "fill": "#ff0000" + }, + { + "label": "Exposure: ${sEXPOSURE}", + "x": 141, + "y": 60, + "id": "oe-field-1", + "fontsize": 20, + "strokewidth": 0, + "tlx": 19, + "tly": 50 + }, + { + "label": "Gain: ${GAIN}", + "x": 83, + "y": 85, + "id": "oe-field-2", + "format": "{:n}", + "fontsize": 20, + "strokewidth": 0, + "tlx": 19, + "tly": 75 + }, + { + "label": "Sensor: ${TEMPERATURE_C} C", + "x": 167, + "y": 110, + "id": "oe-field-3", + "format": "{:.1f}", + "fontsize": 20, + "strokewidth": 0, + "tlx": 19, + "tly": 100, + "sample": "20.1" + }, + { + "label": "${CAMERA_TYPE} ${CAMERA_MODEL}", + "x": 169, + "y": 135, + "id": "oe-field-4", + "fontsize": 16, + "strokewidth": 0, + "sample": "ZWO some_model", + "tlx": 19, + "tly": 125 + } + ], + "images": [], + "settings": { + "defaultdatafileexpiry": "550", + "defaultincludeplanets": false, + "defaultincludesun": false, + "defaultincludemoon": false, + "defaultimagetopacity": 0.63, + "defaultimagerotation": 0, + "defaulttextrotation": 0, + "defaultfontopacity": 1, + "defaultfontcolour": "white", + "defaultfont": "Arial", + "defaultfontsize": 52, + "defaultimagescale": 1, + "defaultnoradids": "" + }, + "fonts": { + "moon_phases": { + "fontPath": "fonts\/moon_phases.ttf", + "fonttcolour": "red", + "backgroundcolor": "white", + "fontopacity": 0.5 + } + }, + "metadata": { + "camerabrand": "ZWO", + "cameramodel": "ASII120MC-S", + "cameraresolutionwidth": "1280", + "cameraresolutionheight": "960", + "tod": "both", + "name": "ASII120MC-S" + } +} diff --git a/config_repo/overlay/config/overlay-ZWO_ASI178MC-3096x2080-both.json b/config_repo/overlay/config/overlay-ZWO_ASI178MC-3096x2080-both.json new file mode 100644 index 000000000..f8fafc98e --- /dev/null +++ b/config_repo/overlay/config/overlay-ZWO_ASI178MC-3096x2080-both.json @@ -0,0 +1,95 @@ +{ + "fields": [ + { + "label": "${DATE} ${TIME}", + "x": 259, + "y": 50, + "id": "oe-field-0", + "format": "{%d-%m-%Y}{%H:%M:%S}", + "strokewidth": 0, + "sample": "DATE,TIME", + "tlx": 19, + "tly": 20, + "fontsize": 60, + "fill": "#ff0000" + }, + { + "label": "Exposure: ${sEXPOSURE}", + "x": 237, + "y": 98, + "id": "oe-field-1", + "fontsize": 36, + "strokewidth": 0, + "tlx": 19, + "tly": 80 + }, + { + "label": "Gain: ${GAIN}", + "x": 134, + "y": 138, + "id": "oe-field-2", + "format": "{:n}", + "fontsize": 36, + "strokewidth": 0, + "tlx": 19, + "tly": 120 + }, + { + "label": "Sensor: ${TEMPERATURE_C} C", + "x": 284, + "y": 178, + "id": "oe-field-3", + "format": "{:.1f}", + "fontsize": 36, + "strokewidth": 0, + "tlx": 19, + "tly": 160, + "sample": "20.1" + }, + { + "label": "${CAMERA_TYPE} ${CAMERA_MODEL}", + "x": 242, + "y": 220, + "id": "oe-field-4", + "fontsize": 24, + "strokewidth": 0, + "sample": "ZWO some_model", + "tlx": 19, + "tly": 208 + } + ], + "images": [], + "settings": { + "defaultdatafileexpiry": "600", + "defaultincludeplanets": true, + "defaultincludesun": true, + "defaultincludemoon": true, + "defaultimagetopacity": 0.63, + "defaultimagerotation": 0, + "defaulttextrotation": 0, + "defaultfontopacity": 1, + "defaultfontcolour": "white", + "defaultfont": "Arial", + "defaultfontsize": 52, + "defaultimagescale": 1, + "defaultnoradids": "25544", + "defaultstrokecolour": "", + "defaultexpirytext": "" + }, + "fonts": { + "moon_phases": { + "fontPath": "fonts\/moon_phases.ttf", + "fonttcolour": "red", + "backgroundcolor": "white", + "fontopacity": 0.5 + } + }, + "metadata": { + "camerabrand": "ZWO", + "cameramodel": "ASI178MC", + "cameraresolutionwidth": "3096", + "cameraresolutionheight": "2080", + "tod": "both", + "name": "ASI178MC" + } +} diff --git a/config_repo/overlay/config/overlay-ZWO_ASI178MM-3096x2080-both.json b/config_repo/overlay/config/overlay-ZWO_ASI178MM-3096x2080-both.json new file mode 100644 index 000000000..b246021bf --- /dev/null +++ b/config_repo/overlay/config/overlay-ZWO_ASI178MM-3096x2080-both.json @@ -0,0 +1,95 @@ +{ + "fields": [ + { + "label": "${DATE} ${TIME}", + "x": 259, + "y": 50, + "id": "oe-field-0", + "format": "{%d-%m-%Y}{%H:%M:%S}", + "strokewidth": 0, + "sample": "DATE,TIME", + "tlx": 19, + "tly": 20, + "fontsize": 60, + "fill": "#ff0000" + }, + { + "label": "Exposure: ${sEXPOSURE}", + "x": 237, + "y": 108, + "id": "oe-field-1", + "fontsize": 36, + "strokewidth": 0, + "tlx": 19, + "tly": 90 + }, + { + "label": "Gain: ${GAIN}", + "x": 134, + "y": 158, + "id": "oe-field-2", + "format": "{:n}", + "fontsize": 36, + "strokewidth": 0, + "tlx": 19, + "tly": 140 + }, + { + "label": "Sensor: ${TEMPERATURE_C} C", + "x": 284, + "y": 178, + "id": "oe-field-3", + "format": "{:.1f}", + "fontsize": 36, + "strokewidth": 0, + "tlx": 19, + "tly": 160, + "sample": "20.1" + }, + { + "label": "${CAMERA_TYPE} ${CAMERA_MODEL}", + "x": 242, + "y": 220, + "id": "oe-field-4", + "fontsize": 24, + "strokewidth": 0, + "sample": "ZWO some_model", + "tlx": 19, + "tly": 208 + } + ], + "images": [], + "settings": { + "defaultdatafileexpiry": "600", + "defaultincludeplanets": true, + "defaultincludesun": true, + "defaultincludemoon": true, + "defaultimagetopacity": 0.63, + "defaultimagerotation": 0, + "defaulttextrotation": 0, + "defaultfontopacity": 1, + "defaultfontcolour": "white", + "defaultfont": "Arial", + "defaultfontsize": 52, + "defaultimagescale": 1, + "defaultnoradids": "25544", + "defaultstrokecolour": "", + "defaultexpirytext": "" + }, + "fonts": { + "moon_phases": { + "fontPath": "fonts\/moon_phases.ttf", + "fonttcolour": "red", + "backgroundcolor": "white", + "fontopacity": 0.5 + } + }, + "metadata": { + "camerabrand": "ZWO", + "cameramodel": "ASI178MM", + "cameraresolutionwidth": "3096", + "cameraresolutionheight": "2080", + "tod": "both", + "name": "ASI178MM" + } +} diff --git a/config_repo/overlay/config/overlay-ZWO_ASI290MC-1936x1096-both.json b/config_repo/overlay/config/overlay-ZWO_ASI290MC-1936x1096-both.json new file mode 100644 index 000000000..2783ebe1b --- /dev/null +++ b/config_repo/overlay/config/overlay-ZWO_ASI290MC-1936x1096-both.json @@ -0,0 +1,95 @@ +{ + "fields": [ + { + "label": "${DATE} ${TIME}", + "x": 259, + "y": 50, + "id": "oe-field-0", + "format": "{%d-%m-%Y}{%H:%M:%S}", + "strokewidth": 0, + "sample": "DATE,TIME", + "tlx": 19, + "tly": 20, + "fontsize": 60, + "fill": "#ff0000" + }, + { + "label": "Exposure: ${sEXPOSURE}", + "x": 237, + "y": 115, + "id": "oe-field-1", + "fontsize": 36, + "strokewidth": 0, + "tlx": 19, + "tly": 90 + }, + { + "label": "Gain: ${GAIN}", + "x": 134, + "y": 180, + "id": "oe-field-2", + "format": "{:n}", + "fontsize": 36, + "strokewidth": 0, + "tlx": 19, + "tly": 155 + }, + { + "label": "Sensor: ${TEMPERATURE_C} C", + "x": 157, + "y": 245, + "id": "oe-field-3", + "format": "{:.1f}", + "fontsize": 36, + "strokewidth": 0, + "tlx": 19, + "tly": 220, + "sample": "20.1" + }, + { + "label": "${CAMERA_TYPE} ${CAMERA_MODEL}", + "x": 159, + "y": 310, + "id": "oe-field-4", + "fontsize": 24, + "strokewidth": 0, + "sample": "ZWO some_model", + "tlx": 19, + "tly": 285 + } + ], + "images": [], + "settings": { + "defaultdatafileexpiry": "600", + "defaultincludeplanets": true, + "defaultincludesun": true, + "defaultincludemoon": true, + "defaultimagetopacity": 0.63, + "defaultimagerotation": 0, + "defaulttextrotation": 0, + "defaultfontopacity": 1, + "defaultfontcolour": "white", + "defaultfont": "Arial", + "defaultfontsize": 52, + "defaultimagescale": 1, + "defaultnoradids": "25544", + "defaultstrokecolour": "", + "defaultexpirytext": "" + }, + "fonts": { + "moon_phases": { + "fontPath": "fonts\/moon_phases.ttf", + "fonttcolour": "red", + "backgroundcolor": "white", + "fontopacity": 0.5 + } + }, + "metadata": { + "camerabrand": "ZWO", + "cameramodel": "ASI290MC", + "cameraresolutionwidth": "1936", + "cameraresolutionheight": "1096", + "tod": "both", + "name": "ASI290MC" + } +} diff --git a/config_repo/overlay/config/overlay-ZWO_ASI290MM-1936x1096-both.json b/config_repo/overlay/config/overlay-ZWO_ASI290MM-1936x1096-both.json new file mode 100644 index 000000000..9301697ef --- /dev/null +++ b/config_repo/overlay/config/overlay-ZWO_ASI290MM-1936x1096-both.json @@ -0,0 +1,96 @@ +{ + "fields": [ + { + "label": "${DATE} ${TIME}", + "x": 259, + "y": 50, + "id": "oe-field-0", + "format": "{%d-%m-%Y}{%H:%M:%S}", + "strokewidth": 0, + "sample": "DATE,TIME", + "tlx": 19, + "tly": 20, + "fontsize": 60, + "fill": "#ff0000" + }, + { + "label": "Exposure: ${sEXPOSURE}", + "x": 237, + "y": 115, + "id": "oe-field-1", + "fontsize": 36, + "strokewidth": 0, + "tlx": 19, + "tly": 90 + }, + { + "label": "Gain: ${GAIN}", + "x": 134, + "y": 180, + "id": "oe-field-2", + "format": "{:n}", + "fontsize": 36, + "strokewidth": 0, + "tlx": 19, + "tly": 155 + }, + { + "label": "Sensor: ${TEMPERATURE_C} C", + "x": 157, + "y": 245, + "id": "oe-field-3", + "format": "{:.1f}", + "fontsize": 36, + "strokewidth": 0, + "tlx": 19, + "tly": 220, + "sample": "20.1" + }, + { + "label": "${CAMERA_TYPE} ${CAMERA_MODEL}", + "x": 159, + "y": 310, + "id": "oe-field-4", + "fontsize": 24, + "strokewidth": 0, + "sample": "ZWO some_model", + "tlx": 19, + "tly": 285 + } + ], + "images": [], + "settings": { + "defaultdatafileexpiry": "600", + "defaultincludeplanets": true, + "defaultincludesun": true, + "defaultincludemoon": true, + "defaultimagetopacity": 0.63, + "defaultimagerotation": 0, + "defaulttextrotation": 0, + "defaultfontopacity": 1, + "defaultfontcolour": "white", + "defaultfont": "Arial", + "defaultfontsize": 52, + "defaultimagescale": 1, + "defaultnoradids": "25544", + "defaultstrokecolour": "", + "defaultexpirytext": "" + }, + "fonts": { + "moon_phases": { + "fontPath": "fonts\/moon_phases.ttf", + "fonttcolour": "red", + "backgroundcolor": "white", + "fontopacity": 0.5 + } + }, + "metadata": { + "camerabrand": "ZWO", + "cameramodel": "ASI290MM", + "cameraresolutionwidth": "1936", + "cameraresolutionheight": "1096", + "tod": "both", + "name": "ASI290MM" + } +} + diff --git a/config_repo/overlay/config/overlay-ZWO_ASI585MC-3840x2160-both.json b/config_repo/overlay/config/overlay-ZWO_ASI585MC-3840x2160-both.json new file mode 100644 index 000000000..dd9dcea5a --- /dev/null +++ b/config_repo/overlay/config/overlay-ZWO_ASI585MC-3840x2160-both.json @@ -0,0 +1,91 @@ +{ + "fields": [ + { + "label": "${DATE} ${TIME}", + "x": 259, + "y": 50, + "id": "oe-field-0", + "format": "{%d-%m-%Y}{%H:%M:%S}", + "strokewidth": 0, + "sample": "DATE,TIME", + "tlx": 19, + "tly": 20, + "fontsize": 60, + "fill": "#ff0000" + }, + { + "label": "Exposure: ${sEXPOSURE} ${sAUTOEXPOSURE}", + "x": 601, + "y": 116, + "id": "oe-field-1", + "strokewidth": 0, + "tlx": 20, + "tly": 90 + }, + { + "label": "Gain: ${GAIN} ${sAUTOGAIN}", + "x": 372.1298828125, + "y": 176, + "id": "oe-field-2", + "strokewidth": 0, + "tlx": 20, + "tly": 150 + }, + { + "label": "Sensor: ${TEMPERATURE_C} C, ${TEMPERATURE_F} F", + "x": 696, + "y": 236, + "id": "oe-field-3", + "format": "{:.1f},{:.1f}", + "strokewidth": 0, + "sample": "32.2", + "tlx": 19, + "tly": 200 + }, + { + "label": "${CAMERA_TYPE} ${CAMERA_MODEL}", + "x": 354.1337890625, + "y": 296, + "id": "oe-field-4", + "fontsize": 36, + "strokewidth": 0, + "tlx": 20, + "tly": 278, + "sample": "ZWO ASI585MC" + } + ], + "images": [], + "settings": { + "defaultdatafileexpiry": "600", + "defaultincludeplanets": true, + "defaultincludesun": true, + "defaultincludemoon": true, + "defaultimagetopacity": 0.63, + "defaultimagerotation": 0, + "defaulttextrotation": 0, + "defaultfontopacity": 1, + "defaultfontcolour": "white", + "defaultfont": "Arial", + "defaultfontsize": 52, + "defaultimagescale": 1, + "defaultnoradids": "25544", + "defaultstrokecolour": "", + "defaultexpirytext": "" + }, + "fonts": { + "moon_phases": { + "fontPath": "fonts\/moon_phases.ttf", + "fonttcolour": "red", + "backgroundcolor": "white", + "fontopacity": 0.5 + } + }, + "metadata": { + "camerabrand": "ZWO", + "cameramodel": "ASI585MC", + "cameraresolutionwidth": "3840", + "cameraresolutionheight": "2160", + "tod": "both", + "name": "ASI585MC" + } +} diff --git a/config_repo/overlay/config/overlay-ZWO_ASI676MC-3552x3552-both.json b/config_repo/overlay/config/overlay-ZWO_ASI676MC-3552x3552-both.json new file mode 100644 index 000000000..744156b3c --- /dev/null +++ b/config_repo/overlay/config/overlay-ZWO_ASI676MC-3552x3552-both.json @@ -0,0 +1,95 @@ +{ + "fields": [ + { + "label": "${DATE} ${TIME}", + "x": 259, + "y": 50, + "id": "oe-field-0", + "format": "{%d-%m-%Y}{%H:%M:%S}", + "strokewidth": 0, + "sample": "DATE,TIME", + "tlx": 19, + "tly": 20, + "fontsize": 60, + "fill": "#ff0000" + }, + { + "label": "Exposure: ${sEXPOSURE}", + "x": 237, + "y": 115, + "id": "oe-field-1", + "fontsize": 36, + "strokewidth": 0, + "tlx": 19, + "tly": 100 + }, + { + "label": "Gain: ${GAIN}", + "x": 134, + "y": 155, + "id": "oe-field-2", + "format": "{:n}", + "fontsize": 36, + "strokewidth": 0, + "tlx": 19, + "tly": 140 + }, + { + "label": "Sensor: ${TEMPERATURE_C} C", + "x": 167, + "y": 195, + "id": "oe-field-3", + "format": "{:.1f}", + "fontsize": 20, + "strokewidth": 0, + "tlx": 19, + "tly": 180, + "sample": "20.1" + }, + { + "label": "${CAMERA_TYPE} ${CAMERA_MODEL}", + "x": 169, + "y": 220, + "id": "oe-field-4", + "fontsize": 16, + "strokewidth": 0, + "sample": "ZWO some_model", + "tlx": 19, + "tly": 205 + } + ], + "images": [], + "settings": { + "defaultdatafileexpiry": "600", + "defaultincludeplanets": true, + "defaultincludesun": true, + "defaultincludemoon": true, + "defaultimagetopacity": 0.63, + "defaultimagerotation": 0, + "defaulttextrotation": 0, + "defaultfontopacity": 1, + "defaultfontcolour": "white", + "defaultfont": "Arial", + "defaultfontsize": 52, + "defaultimagescale": 1, + "defaultnoradids": "25544", + "defaultstrokecolour": "", + "defaultexpirytext": "" + }, + "fonts": { + "moon_phases": { + "fontPath": "fonts\/moon_phases.ttf", + "fonttcolour": "red", + "backgroundcolor": "white", + "fontopacity": 0.5 + } + }, + "metadata": { + "camerabrand": "ZWO", + "cameramodel": "ASI676MC", + "cameraresolutionwidth": "3552", + "cameraresolutionheight": "3552", + "tod": "both", + "name": "ASI676MC" + } +} diff --git a/config_repo/requirements-buster.txt b/config_repo/requirements-buster.txt index c079217a3..726592113 100644 --- a/config_repo/requirements-buster.txt +++ b/config_repo/requirements-buster.txt @@ -7,7 +7,6 @@ astral pytz scipy<=1.8.1 paho-mqtt -astropy==4.3.1 suncalc Adafruit-Blinka vcgencmd diff --git a/config_repo/sudoers.repo b/config_repo/sudoers.repo index 2fbdb383f..73ce47db2 100644 --- a/config_repo/sudoers.repo +++ b/config_repo/sudoers.repo @@ -1,8 +1,8 @@ # Permissions for allsky webserver -www-data ALL=(ALL) NOPASSWD:/sbin/ifdown wlan0 -www-data ALL=(ALL) NOPASSWD:/sbin/ifup wlan0 -www-data ALL=(ALL) NOPASSWD:/sbin/ifdown eth0 -www-data ALL=(ALL) NOPASSWD:/sbin/ifup eth0 +www-data ALL=(ALL) NOPASSWD:/sbin/ifdown +www-data ALL=(ALL) NOPASSWD:/sbin/ifup +www-data ALL=(ALL) NOPASSWD:/sbin/ifdown +www-data ALL=(ALL) NOPASSWD:/sbin/ifup www-data ALL=(ALL) NOPASSWD:/bin/cat /etc/wpa_supplicant/wpa_supplicant.conf www-data ALL=(ALL) NOPASSWD:/bin/cp /tmp/wifidata /etc/wpa_supplicant/wpa_supplicant.conf www-data ALL=(ALL) NOPASSWD:/sbin/wpa_cli scan_results @@ -24,9 +24,11 @@ www-data ALL=(ALL) NOPASSWD:/bin/rm www-data ALL=(ALL) NOPASSWD:/bin/cp www-data ALL=(ALL) NOPASSWD:/bin/mv www-data ALL=(ALL) NOPASSWD:/usr/bin/vcgencmd -www-data ALL=(ALL) NOPASSWD:XX_ALLSKY_SCRIPTS_XX/postData.sh www-data ALL=(ALL) NOPASSWD:/usr/sbin/ifconfig www-data ALL=(ALL) NOPASSWD:/usr/bin/truncate www-data ALL=(ALL) NOPASSWD:XX_ALLSKY_SCRIPTS_XX/upload.sh www-data ALL=(ALL) NOPASSWD:XX_ALLSKY_SCRIPTS_XX/makeChanges.sh +www-data ALL=(ALL) NOPASSWD:XX_ALLSKY_SCRIPTS_XX/postData.sh +www-data ALL=(ALL) NOPASSWD:XX_ALLSKY_SCRIPTS_XX/checkAllsky.sh www-data ALL=(ALL) NOPASSWD:/usr/bin/date +www-data ALL=(ALL) NOPASSWD:/usr/bin/touch diff --git a/html/allsky/NoThumbnail.png b/html/allsky/NoThumbnail.png new file mode 100644 index 000000000..47480fe15 Binary files /dev/null and b/html/allsky/NoThumbnail.png differ diff --git a/html/allsky/allsky-favicon.png b/html/allsky/allsky-favicon.png new file mode 100644 index 000000000..7b350606e Binary files /dev/null and b/html/allsky/allsky-favicon.png differ diff --git a/html/allsky/allsky-font.css b/html/allsky/allsky-font.css new file mode 100755 index 000000000..cb0edaedd --- /dev/null +++ b/html/allsky/allsky-font.css @@ -0,0 +1,60 @@ +@font-face { + font-family: 'allsky'; + src: url('fonts/allsky.eot?thds7v'); + src: url('fonts/allsky.eot?thds7v#iefix') format('embedded-opentype'), + url('fonts/allsky.ttf?thds7v') format('truetype'), + url('fonts/allsky.woff?thds7v') format('woff'), + url('fonts/allsky.svg?thds7v#allsky') format('svg'); + font-weight: normal; + font-style: normal; +} + +[class^="allsky-"], [class*=" allsky-"] { + /* use !important to prevent issues with browser extensions that change fonts */ + font-family: 'allsky' !important; + speak: none; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + line-height: 1; + + /* Better Font Rendering =========== */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.allsky-constellation:before { + content: "\e900"; +} + +@font-face { + font-family: 'mini-timelapse'; + src: url('fonts/mini-timelapse.eot?dynh3c'); + src: url('fonts/mini-timelapse.eot?dynh3c#iefix') format('embedded-opentype'), + url('fonts/mini-timelapse.ttf?dynh3c') format('truetype'), + url('fonts/mini-timelapse.woff?dynh3c') format('woff'), + url('fonts/mini-timelapse.svg?dynh3c#mini-timelapse') format('svg'); + font-weight: normal; + font-style: normal; + font-display: block; +} + +[class^="icon-"], [class*=" icon-"] { + /* use !important to prevent issues with browser extensions that change fonts */ + font-family: 'mini-timelapse' !important; + speak: never; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + line-height: 1; + + /* Better Font Rendering =========== */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.icon-mini-timelapse:before { + content: "\ea15"; +} diff --git a/html/allsky/allsky-logo.png b/html/allsky/allsky-logo.png new file mode 100644 index 000000000..8fc3b426f Binary files /dev/null and b/html/allsky/allsky-logo.png differ diff --git a/html/allsky/allsky.css b/html/allsky/allsky.css new file mode 100755 index 000000000..20979ed03 --- /dev/null +++ b/html/allsky/allsky.css @@ -0,0 +1,299 @@ +:root { + --error-color: #dc3545; + --warning-color: #ffc107; + --notice-color: white; +} + +body { + color:white; + font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; + background-color: black; + max-width: 960px; + margin: auto; + overflow-y: scroll; +} +img.current { + width: 100%; + max-width: 960px; +} + +#starmap_container { + position: absolute; + overflow: hidden; +} + +.header { + display:block; + width: 100%; +} + +.title { + float: left; + color: #DDD; + font-size: 20px; + padding-left: 65px; + padding-top: 4px; + margin-left: 5px; + margin-top: 2px; + background: url('allsky-logo.png') left center; + background-size: 57px; + background-repeat: no-repeat; + font-family: 'Ubuntu', sans-serif; + font-weight: 400; + height: 35px; +} + +.personalLink { + text-align: center; + font-size: 150%; +} + +.info { + font-size: 90%; + padding: 8px; + position: fixed; + top: 150px; + right: 0px; + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + background-color: #333; + border: 2px solid #888; + border-right: none; + z-index: 1; +} + +.info ul { + list-style: none; + padding-left: 2px; + margin-bottom: 0; +} + +.info ul li i { + margin-right: 3px; +} + +#leftSidebar { + position: fixed; + left: 0; + top: 150px; + padding: 5px; + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + background-color: #333; + border: 2px solid #888; + border-left: none; + z-index: 2; +} + +#leftSidebar li { + list-style: none; + padding: 2px 0 +} + +#leftSidebar li i { + cursor: pointer; + color: #888; + margin: 2px 0; +} + +#leftSidebar li i:hover, +#leftSidebar li i.active { + color: orange; +} + +#imageContainer { + margin: auto; +} +.imageContainer { + border: 1px solid #5a5a5a; +} + +.starmap_credit { + display: none !important; +} + +.diy { + position: fixed; + bottom: 10px; + right: 10px; + opacity: 0.5; +} + +.diy:hover { + opacity: 1; +} + +.diy a { + color: white; +} + +.diy i { + margin-right: 5px; +} + +.noImages { + text-align: center; + font-size: 200%; + color: #ffc107; + border: 2px solid gray; + margin: 4px; +} + +.imagesHeader { /* the whole table */ + width: 100%; + padding: 5px 5px 20px 20px; +} +.imagesHeader .headerButton { + text-align: left; + width: 5%; /* want headerTitle to be as wide as possible */ +} +.imagesHeader .headerTitle { + text-align: center; + font-weight: bold; + font-size: 150%; +} + +.back-button { + text-decoration: none; + margin: 5px; + padding: 5px 10px; + color: white; + background-color: #5D5D5D; + border-radius: 5px; + white-space: nowrap; +} + +.back-button i { + margin-right: 5px; +} + +.archived-files { +} + +.archived-files .day-container{ + /* width: 10%; */ + min-width: 100px; /* same as thumbnail width */ + float: left; + padding: 8px; + text-align: center; /* so text is centered below thumbnail */ +} + +.archived-files .img-text { + border: 1px solid #333; +} + +.archived-files .day-container .day-text { + padding-top: 2px; + color: #7777ff; +} + +.archived-files .day-container .image-container { + height: 100px; /* based on 100px wide thumbnail */ +} + +.archived-files .day-container .image-container img{ + max-width: 100%; + max-height: 100%; +} + +.archived-files-end { + clear: both; +} + +.archived-files hr { + margin-top: 25px; + width: 50%; + height: 2px; + background-color: #7777ffaa; + border-radius: 2px; + border: 1px solid #7777ffaa; +} + +.forecast { + padding: 9px; +} + +.forecast-day { + font-weight: bold; + margin: 0 3px; +} + +.forecast .Very_Quite, +.forecast .Quiet { + color: green; +} + +.forecast .Unsettled, +.forecast .Active { + color: #ffc107; +} + +.forecast .Minor_Storm, +.forecast .Moderate_Storm { + color: darkorange; +} + +.forecast .Strong_Storm, +.forecast .Severe_Storm, +.forecast .Extreme_Storm { + color: #dc3545; +} + +.forecast .WARNING { /* for Aurora activity */ + color: #ffc107; + font-weight: bold; + font-size: 125%; +} + +.forecast-map { + width: 75%; + max-width: 800px; + margin-top: 30px; +} + +.virtualsky_help { + color: black; +} + +.thumbnailError { + color: #dc3545; +} + +/* Messages on the home page */ +.msg { + background-color: #222; + text-align: center; + font-size: 145%; + font-weight: bold; + margin: 10px 0 20px 0; + padding: 20px 0 20px 0; + border-radius: 10px; +} +.error-msg { + font-size: 150%; + margin: 10px; + padding: 10px; + color: var(--error-color); + border: 3px dashed var(--error-color); +} +.warning-msg { + font-size: 125%; + margin: 10px; + padding: 10px; + color: var(--warning-color); + border: 3px dashed var(--warning-color); +} +.notice-msg { + font-size: 110%; + margin: 10px; + padding: 10px; + color: var(--notice-color); + border: 3px solid var(--notice-color); +} + +@media screen and (max-width: 480px) { + .msg { + font-size: 100%; + } +} + diff --git a/html/allsky/analyticsTracking.js b/html/allsky/analyticsTracking.js new file mode 100644 index 000000000..d117a106c --- /dev/null +++ b/html/allsky/analyticsTracking.js @@ -0,0 +1 @@ +// Include your Google Analytics code here \ No newline at end of file diff --git a/html/allsky/animate.min.css b/html/allsky/animate.min.css new file mode 100755 index 000000000..b6f612953 --- /dev/null +++ b/html/allsky/animate.min.css @@ -0,0 +1,11 @@ +@charset "UTF-8"; + +/*! + * animate.css -http://daneden.me/animate + * Version - 3.5.1 + * Licensed under the MIT license - http://opensource.org/licenses/MIT + * + * Copyright (c) 2016 Daniel Eden + */ + +.animated{-webkit-animation-duration:1s;animation-duration:1s;-webkit-animation-fill-mode:both;animation-fill-mode:both}.animated.infinite{-webkit-animation-iteration-count:infinite;animation-iteration-count:infinite}.animated.hinge{-webkit-animation-duration:2s;animation-duration:2s}.animated.bounceIn,.animated.bounceOut,.animated.flipOutX,.animated.flipOutY{-webkit-animation-duration:.75s;animation-duration:.75s}@-webkit-keyframes bounce{0%,20%,53%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1);-webkit-transform:translateZ(0);transform:translateZ(0)}40%,43%{-webkit-transform:translate3d(0,-30px,0);transform:translate3d(0,-30px,0)}40%,43%,70%{-webkit-animation-timing-function:cubic-bezier(.755,.05,.855,.06);animation-timing-function:cubic-bezier(.755,.05,.855,.06)}70%{-webkit-transform:translate3d(0,-15px,0);transform:translate3d(0,-15px,0)}90%{-webkit-transform:translate3d(0,-4px,0);transform:translate3d(0,-4px,0)}}@keyframes bounce{0%,20%,53%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1);-webkit-transform:translateZ(0);transform:translateZ(0)}40%,43%{-webkit-transform:translate3d(0,-30px,0);transform:translate3d(0,-30px,0)}40%,43%,70%{-webkit-animation-timing-function:cubic-bezier(.755,.05,.855,.06);animation-timing-function:cubic-bezier(.755,.05,.855,.06)}70%{-webkit-transform:translate3d(0,-15px,0);transform:translate3d(0,-15px,0)}90%{-webkit-transform:translate3d(0,-4px,0);transform:translate3d(0,-4px,0)}}.bounce{-webkit-animation-name:bounce;animation-name:bounce;-webkit-transform-origin:center bottom;transform-origin:center bottom}@-webkit-keyframes flash{0%,50%,to{opacity:1}25%,75%{opacity:0}}@keyframes flash{0%,50%,to{opacity:1}25%,75%{opacity:0}}.flash{-webkit-animation-name:flash;animation-name:flash}@-webkit-keyframes pulse{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}50%{-webkit-transform:scale3d(1.05,1.05,1.05);transform:scale3d(1.05,1.05,1.05)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes pulse{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}50%{-webkit-transform:scale3d(1.05,1.05,1.05);transform:scale3d(1.05,1.05,1.05)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}.pulse{-webkit-animation-name:pulse;animation-name:pulse}@-webkit-keyframes rubberBand{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}30%{-webkit-transform:scale3d(1.25,.75,1);transform:scale3d(1.25,.75,1)}40%{-webkit-transform:scale3d(.75,1.25,1);transform:scale3d(.75,1.25,1)}50%{-webkit-transform:scale3d(1.15,.85,1);transform:scale3d(1.15,.85,1)}65%{-webkit-transform:scale3d(.95,1.05,1);transform:scale3d(.95,1.05,1)}75%{-webkit-transform:scale3d(1.05,.95,1);transform:scale3d(1.05,.95,1)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes rubberBand{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}30%{-webkit-transform:scale3d(1.25,.75,1);transform:scale3d(1.25,.75,1)}40%{-webkit-transform:scale3d(.75,1.25,1);transform:scale3d(.75,1.25,1)}50%{-webkit-transform:scale3d(1.15,.85,1);transform:scale3d(1.15,.85,1)}65%{-webkit-transform:scale3d(.95,1.05,1);transform:scale3d(.95,1.05,1)}75%{-webkit-transform:scale3d(1.05,.95,1);transform:scale3d(1.05,.95,1)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}.rubberBand{-webkit-animation-name:rubberBand;animation-name:rubberBand}@-webkit-keyframes shake{0%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}10%,30%,50%,70%,90%{-webkit-transform:translate3d(-10px,0,0);transform:translate3d(-10px,0,0)}20%,40%,60%,80%{-webkit-transform:translate3d(10px,0,0);transform:translate3d(10px,0,0)}}@keyframes shake{0%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}10%,30%,50%,70%,90%{-webkit-transform:translate3d(-10px,0,0);transform:translate3d(-10px,0,0)}20%,40%,60%,80%{-webkit-transform:translate3d(10px,0,0);transform:translate3d(10px,0,0)}}.shake{-webkit-animation-name:shake;animation-name:shake}@-webkit-keyframes headShake{0%{-webkit-transform:translateX(0);transform:translateX(0)}6.5%{-webkit-transform:translateX(-6px) rotateY(-9deg);transform:translateX(-6px) rotateY(-9deg)}18.5%{-webkit-transform:translateX(5px) rotateY(7deg);transform:translateX(5px) rotateY(7deg)}31.5%{-webkit-transform:translateX(-3px) rotateY(-5deg);transform:translateX(-3px) rotateY(-5deg)}43.5%{-webkit-transform:translateX(2px) rotateY(3deg);transform:translateX(2px) rotateY(3deg)}50%{-webkit-transform:translateX(0);transform:translateX(0)}}@keyframes headShake{0%{-webkit-transform:translateX(0);transform:translateX(0)}6.5%{-webkit-transform:translateX(-6px) rotateY(-9deg);transform:translateX(-6px) rotateY(-9deg)}18.5%{-webkit-transform:translateX(5px) rotateY(7deg);transform:translateX(5px) rotateY(7deg)}31.5%{-webkit-transform:translateX(-3px) rotateY(-5deg);transform:translateX(-3px) rotateY(-5deg)}43.5%{-webkit-transform:translateX(2px) rotateY(3deg);transform:translateX(2px) rotateY(3deg)}50%{-webkit-transform:translateX(0);transform:translateX(0)}}.headShake{-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;-webkit-animation-name:headShake;animation-name:headShake}@-webkit-keyframes swing{20%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}40%{-webkit-transform:rotate(-10deg);transform:rotate(-10deg)}60%{-webkit-transform:rotate(5deg);transform:rotate(5deg)}80%{-webkit-transform:rotate(-5deg);transform:rotate(-5deg)}to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@keyframes swing{20%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}40%{-webkit-transform:rotate(-10deg);transform:rotate(-10deg)}60%{-webkit-transform:rotate(5deg);transform:rotate(5deg)}80%{-webkit-transform:rotate(-5deg);transform:rotate(-5deg)}to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}.swing{-webkit-transform-origin:top center;transform-origin:top center;-webkit-animation-name:swing;animation-name:swing}@-webkit-keyframes tada{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}10%,20%{-webkit-transform:scale3d(.9,.9,.9) rotate(-3deg);transform:scale3d(.9,.9,.9) rotate(-3deg)}30%,50%,70%,90%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(3deg);transform:scale3d(1.1,1.1,1.1) rotate(3deg)}40%,60%,80%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(-3deg);transform:scale3d(1.1,1.1,1.1) rotate(-3deg)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes tada{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}10%,20%{-webkit-transform:scale3d(.9,.9,.9) rotate(-3deg);transform:scale3d(.9,.9,.9) rotate(-3deg)}30%,50%,70%,90%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(3deg);transform:scale3d(1.1,1.1,1.1) rotate(3deg)}40%,60%,80%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(-3deg);transform:scale3d(1.1,1.1,1.1) rotate(-3deg)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}.tada{-webkit-animation-name:tada;animation-name:tada}@-webkit-keyframes wobble{0%{-webkit-transform:none;transform:none}15%{-webkit-transform:translate3d(-25%,0,0) rotate(-5deg);transform:translate3d(-25%,0,0) rotate(-5deg)}30%{-webkit-transform:translate3d(20%,0,0) rotate(3deg);transform:translate3d(20%,0,0) rotate(3deg)}45%{-webkit-transform:translate3d(-15%,0,0) rotate(-3deg);transform:translate3d(-15%,0,0) rotate(-3deg)}60%{-webkit-transform:translate3d(10%,0,0) rotate(2deg);transform:translate3d(10%,0,0) rotate(2deg)}75%{-webkit-transform:translate3d(-5%,0,0) rotate(-1deg);transform:translate3d(-5%,0,0) rotate(-1deg)}to{-webkit-transform:none;transform:none}}@keyframes wobble{0%{-webkit-transform:none;transform:none}15%{-webkit-transform:translate3d(-25%,0,0) rotate(-5deg);transform:translate3d(-25%,0,0) rotate(-5deg)}30%{-webkit-transform:translate3d(20%,0,0) rotate(3deg);transform:translate3d(20%,0,0) rotate(3deg)}45%{-webkit-transform:translate3d(-15%,0,0) rotate(-3deg);transform:translate3d(-15%,0,0) rotate(-3deg)}60%{-webkit-transform:translate3d(10%,0,0) rotate(2deg);transform:translate3d(10%,0,0) rotate(2deg)}75%{-webkit-transform:translate3d(-5%,0,0) rotate(-1deg);transform:translate3d(-5%,0,0) rotate(-1deg)}to{-webkit-transform:none;transform:none}}.wobble{-webkit-animation-name:wobble;animation-name:wobble}@-webkit-keyframes jello{0%,11.1%,to{-webkit-transform:none;transform:none}22.2%{-webkit-transform:skewX(-12.5deg) skewY(-12.5deg);transform:skewX(-12.5deg) skewY(-12.5deg)}33.3%{-webkit-transform:skewX(6.25deg) skewY(6.25deg);transform:skewX(6.25deg) skewY(6.25deg)}44.4%{-webkit-transform:skewX(-3.125deg) skewY(-3.125deg);transform:skewX(-3.125deg) skewY(-3.125deg)}55.5%{-webkit-transform:skewX(1.5625deg) skewY(1.5625deg);transform:skewX(1.5625deg) skewY(1.5625deg)}66.6%{-webkit-transform:skewX(-.78125deg) skewY(-.78125deg);transform:skewX(-.78125deg) skewY(-.78125deg)}77.7%{-webkit-transform:skewX(.390625deg) skewY(.390625deg);transform:skewX(.390625deg) skewY(.390625deg)}88.8%{-webkit-transform:skewX(-.1953125deg) skewY(-.1953125deg);transform:skewX(-.1953125deg) skewY(-.1953125deg)}}@keyframes jello{0%,11.1%,to{-webkit-transform:none;transform:none}22.2%{-webkit-transform:skewX(-12.5deg) skewY(-12.5deg);transform:skewX(-12.5deg) skewY(-12.5deg)}33.3%{-webkit-transform:skewX(6.25deg) skewY(6.25deg);transform:skewX(6.25deg) skewY(6.25deg)}44.4%{-webkit-transform:skewX(-3.125deg) skewY(-3.125deg);transform:skewX(-3.125deg) skewY(-3.125deg)}55.5%{-webkit-transform:skewX(1.5625deg) skewY(1.5625deg);transform:skewX(1.5625deg) skewY(1.5625deg)}66.6%{-webkit-transform:skewX(-.78125deg) skewY(-.78125deg);transform:skewX(-.78125deg) skewY(-.78125deg)}77.7%{-webkit-transform:skewX(.390625deg) skewY(.390625deg);transform:skewX(.390625deg) skewY(.390625deg)}88.8%{-webkit-transform:skewX(-.1953125deg) skewY(-.1953125deg);transform:skewX(-.1953125deg) skewY(-.1953125deg)}}.jello{-webkit-animation-name:jello;animation-name:jello;-webkit-transform-origin:center;transform-origin:center}@-webkit-keyframes bounceIn{0%,20%,40%,60%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}20%{-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}40%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}60%{opacity:1;-webkit-transform:scale3d(1.03,1.03,1.03);transform:scale3d(1.03,1.03,1.03)}80%{-webkit-transform:scale3d(.97,.97,.97);transform:scale3d(.97,.97,.97)}to{opacity:1;-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes bounceIn{0%,20%,40%,60%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}20%{-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}40%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}60%{opacity:1;-webkit-transform:scale3d(1.03,1.03,1.03);transform:scale3d(1.03,1.03,1.03)}80%{-webkit-transform:scale3d(.97,.97,.97);transform:scale3d(.97,.97,.97)}to{opacity:1;-webkit-transform:scaleX(1);transform:scaleX(1)}}.bounceIn{-webkit-animation-name:bounceIn;animation-name:bounceIn}@-webkit-keyframes bounceInDown{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,-3000px,0);transform:translate3d(0,-3000px,0)}60%{opacity:1;-webkit-transform:translate3d(0,25px,0);transform:translate3d(0,25px,0)}75%{-webkit-transform:translate3d(0,-10px,0);transform:translate3d(0,-10px,0)}90%{-webkit-transform:translate3d(0,5px,0);transform:translate3d(0,5px,0)}to{-webkit-transform:none;transform:none}}@keyframes bounceInDown{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,-3000px,0);transform:translate3d(0,-3000px,0)}60%{opacity:1;-webkit-transform:translate3d(0,25px,0);transform:translate3d(0,25px,0)}75%{-webkit-transform:translate3d(0,-10px,0);transform:translate3d(0,-10px,0)}90%{-webkit-transform:translate3d(0,5px,0);transform:translate3d(0,5px,0)}to{-webkit-transform:none;transform:none}}.bounceInDown{-webkit-animation-name:bounceInDown;animation-name:bounceInDown}@-webkit-keyframes bounceInLeft{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(-3000px,0,0);transform:translate3d(-3000px,0,0)}60%{opacity:1;-webkit-transform:translate3d(25px,0,0);transform:translate3d(25px,0,0)}75%{-webkit-transform:translate3d(-10px,0,0);transform:translate3d(-10px,0,0)}90%{-webkit-transform:translate3d(5px,0,0);transform:translate3d(5px,0,0)}to{-webkit-transform:none;transform:none}}@keyframes bounceInLeft{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(-3000px,0,0);transform:translate3d(-3000px,0,0)}60%{opacity:1;-webkit-transform:translate3d(25px,0,0);transform:translate3d(25px,0,0)}75%{-webkit-transform:translate3d(-10px,0,0);transform:translate3d(-10px,0,0)}90%{-webkit-transform:translate3d(5px,0,0);transform:translate3d(5px,0,0)}to{-webkit-transform:none;transform:none}}.bounceInLeft{-webkit-animation-name:bounceInLeft;animation-name:bounceInLeft}@-webkit-keyframes bounceInRight{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(3000px,0,0);transform:translate3d(3000px,0,0)}60%{opacity:1;-webkit-transform:translate3d(-25px,0,0);transform:translate3d(-25px,0,0)}75%{-webkit-transform:translate3d(10px,0,0);transform:translate3d(10px,0,0)}90%{-webkit-transform:translate3d(-5px,0,0);transform:translate3d(-5px,0,0)}to{-webkit-transform:none;transform:none}}@keyframes bounceInRight{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(3000px,0,0);transform:translate3d(3000px,0,0)}60%{opacity:1;-webkit-transform:translate3d(-25px,0,0);transform:translate3d(-25px,0,0)}75%{-webkit-transform:translate3d(10px,0,0);transform:translate3d(10px,0,0)}90%{-webkit-transform:translate3d(-5px,0,0);transform:translate3d(-5px,0,0)}to{-webkit-transform:none;transform:none}}.bounceInRight{-webkit-animation-name:bounceInRight;animation-name:bounceInRight}@-webkit-keyframes bounceInUp{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,3000px,0);transform:translate3d(0,3000px,0)}60%{opacity:1;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}75%{-webkit-transform:translate3d(0,10px,0);transform:translate3d(0,10px,0)}90%{-webkit-transform:translate3d(0,-5px,0);transform:translate3d(0,-5px,0)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes bounceInUp{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,3000px,0);transform:translate3d(0,3000px,0)}60%{opacity:1;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}75%{-webkit-transform:translate3d(0,10px,0);transform:translate3d(0,10px,0)}90%{-webkit-transform:translate3d(0,-5px,0);transform:translate3d(0,-5px,0)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.bounceInUp{-webkit-animation-name:bounceInUp;animation-name:bounceInUp}@-webkit-keyframes bounceOut{20%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}50%,55%{opacity:1;-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}to{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}}@keyframes bounceOut{20%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}50%,55%{opacity:1;-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}to{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}}.bounceOut{-webkit-animation-name:bounceOut;animation-name:bounceOut}@-webkit-keyframes bounceOutDown{20%{-webkit-transform:translate3d(0,10px,0);transform:translate3d(0,10px,0)}40%,45%{opacity:1;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}}@keyframes bounceOutDown{20%{-webkit-transform:translate3d(0,10px,0);transform:translate3d(0,10px,0)}40%,45%{opacity:1;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}}.bounceOutDown{-webkit-animation-name:bounceOutDown;animation-name:bounceOutDown}@-webkit-keyframes bounceOutLeft{20%{opacity:1;-webkit-transform:translate3d(20px,0,0);transform:translate3d(20px,0,0)}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}}@keyframes bounceOutLeft{20%{opacity:1;-webkit-transform:translate3d(20px,0,0);transform:translate3d(20px,0,0)}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}}.bounceOutLeft{-webkit-animation-name:bounceOutLeft;animation-name:bounceOutLeft}@-webkit-keyframes bounceOutRight{20%{opacity:1;-webkit-transform:translate3d(-20px,0,0);transform:translate3d(-20px,0,0)}to{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}}@keyframes bounceOutRight{20%{opacity:1;-webkit-transform:translate3d(-20px,0,0);transform:translate3d(-20px,0,0)}to{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}}.bounceOutRight{-webkit-animation-name:bounceOutRight;animation-name:bounceOutRight}@-webkit-keyframes bounceOutUp{20%{-webkit-transform:translate3d(0,-10px,0);transform:translate3d(0,-10px,0)}40%,45%{opacity:1;-webkit-transform:translate3d(0,20px,0);transform:translate3d(0,20px,0)}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}}@keyframes bounceOutUp{20%{-webkit-transform:translate3d(0,-10px,0);transform:translate3d(0,-10px,0)}40%,45%{opacity:1;-webkit-transform:translate3d(0,20px,0);transform:translate3d(0,20px,0)}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}}.bounceOutUp{-webkit-animation-name:bounceOutUp;animation-name:bounceOutUp}@-webkit-keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}.fadeIn{-webkit-animation-name:fadeIn;animation-name:fadeIn}@-webkit-keyframes fadeInDown{0%{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}to{opacity:1;-webkit-transform:none;transform:none}}@keyframes fadeInDown{0%{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}to{opacity:1;-webkit-transform:none;transform:none}}.fadeInDown{-webkit-animation-name:fadeInDown;animation-name:fadeInDown}@-webkit-keyframes fadeInDownBig{0%{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}to{opacity:1;-webkit-transform:none;transform:none}}@keyframes fadeInDownBig{0%{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}to{opacity:1;-webkit-transform:none;transform:none}}.fadeInDownBig{-webkit-animation-name:fadeInDownBig;animation-name:fadeInDownBig}@-webkit-keyframes fadeInLeft{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}to{opacity:1;-webkit-transform:none;transform:none}}@keyframes fadeInLeft{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}to{opacity:1;-webkit-transform:none;transform:none}}.fadeInLeft{-webkit-animation-name:fadeInLeft;animation-name:fadeInLeft}@-webkit-keyframes fadeInLeftBig{0%{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}to{opacity:1;-webkit-transform:none;transform:none}}@keyframes fadeInLeftBig{0%{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}to{opacity:1;-webkit-transform:none;transform:none}}.fadeInLeftBig{-webkit-animation-name:fadeInLeftBig;animation-name:fadeInLeftBig}@-webkit-keyframes fadeInRight{0%{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}to{opacity:1;-webkit-transform:none;transform:none}}@keyframes fadeInRight{0%{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}to{opacity:1;-webkit-transform:none;transform:none}}.fadeInRight{-webkit-animation-name:fadeInRight;animation-name:fadeInRight}@-webkit-keyframes fadeInRightBig{0%{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}to{opacity:1;-webkit-transform:none;transform:none}}@keyframes fadeInRightBig{0%{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}to{opacity:1;-webkit-transform:none;transform:none}}.fadeInRightBig{-webkit-animation-name:fadeInRightBig;animation-name:fadeInRightBig}@-webkit-keyframes fadeInUp{0%{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}to{opacity:1;-webkit-transform:none;transform:none}}@keyframes fadeInUp{0%{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}to{opacity:1;-webkit-transform:none;transform:none}}.fadeInUp{-webkit-animation-name:fadeInUp;animation-name:fadeInUp}@-webkit-keyframes fadeInUpBig{0%{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}to{opacity:1;-webkit-transform:none;transform:none}}@keyframes fadeInUpBig{0%{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}to{opacity:1;-webkit-transform:none;transform:none}}.fadeInUpBig{-webkit-animation-name:fadeInUpBig;animation-name:fadeInUpBig}@-webkit-keyframes fadeOut{0%{opacity:1}to{opacity:0}}@keyframes fadeOut{0%{opacity:1}to{opacity:0}}.fadeOut{-webkit-animation-name:fadeOut;animation-name:fadeOut}@-webkit-keyframes fadeOutDown{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}@keyframes fadeOutDown{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}.fadeOutDown{-webkit-animation-name:fadeOutDown;animation-name:fadeOutDown}@-webkit-keyframes fadeOutDownBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}}@keyframes fadeOutDownBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}}.fadeOutDownBig{-webkit-animation-name:fadeOutDownBig;animation-name:fadeOutDownBig}@-webkit-keyframes fadeOutLeft{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}@keyframes fadeOutLeft{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}.fadeOutLeft{-webkit-animation-name:fadeOutLeft;animation-name:fadeOutLeft}@-webkit-keyframes fadeOutLeftBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}}@keyframes fadeOutLeftBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}}.fadeOutLeftBig{-webkit-animation-name:fadeOutLeftBig;animation-name:fadeOutLeftBig}@-webkit-keyframes fadeOutRight{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}@keyframes fadeOutRight{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}.fadeOutRight{-webkit-animation-name:fadeOutRight;animation-name:fadeOutRight}@-webkit-keyframes fadeOutRightBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}}@keyframes fadeOutRightBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}}.fadeOutRightBig{-webkit-animation-name:fadeOutRightBig;animation-name:fadeOutRightBig}@-webkit-keyframes fadeOutUp{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}@keyframes fadeOutUp{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}.fadeOutUp{-webkit-animation-name:fadeOutUp;animation-name:fadeOutUp}@-webkit-keyframes fadeOutUpBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}}@keyframes fadeOutUpBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}}.fadeOutUpBig{-webkit-animation-name:fadeOutUpBig;animation-name:fadeOutUpBig}@-webkit-keyframes flip{0%{-webkit-transform:perspective(400px) rotateY(-1turn);transform:perspective(400px) rotateY(-1turn)}0%,40%{-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}40%{-webkit-transform:perspective(400px) translateZ(150px) rotateY(-190deg);transform:perspective(400px) translateZ(150px) rotateY(-190deg)}50%{-webkit-transform:perspective(400px) translateZ(150px) rotateY(-170deg);transform:perspective(400px) translateZ(150px) rotateY(-170deg)}50%,80%{-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}80%{-webkit-transform:perspective(400px) scale3d(.95,.95,.95);transform:perspective(400px) scale3d(.95,.95,.95)}to{-webkit-transform:perspective(400px);transform:perspective(400px);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}}@keyframes flip{0%{-webkit-transform:perspective(400px) rotateY(-1turn);transform:perspective(400px) rotateY(-1turn)}0%,40%{-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}40%{-webkit-transform:perspective(400px) translateZ(150px) rotateY(-190deg);transform:perspective(400px) translateZ(150px) rotateY(-190deg)}50%{-webkit-transform:perspective(400px) translateZ(150px) rotateY(-170deg);transform:perspective(400px) translateZ(150px) rotateY(-170deg)}50%,80%{-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}80%{-webkit-transform:perspective(400px) scale3d(.95,.95,.95);transform:perspective(400px) scale3d(.95,.95,.95)}to{-webkit-transform:perspective(400px);transform:perspective(400px);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}}.animated.flip{-webkit-backface-visibility:visible;backface-visibility:visible;-webkit-animation-name:flip;animation-name:flip}@-webkit-keyframes flipInX{0%{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);opacity:0}0%,40%{-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}40%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg)}60%{-webkit-transform:perspective(400px) rotateX(10deg);transform:perspective(400px) rotateX(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateX(-5deg);transform:perspective(400px) rotateX(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}@keyframes flipInX{0%{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);opacity:0}0%,40%{-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}40%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg)}60%{-webkit-transform:perspective(400px) rotateX(10deg);transform:perspective(400px) rotateX(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateX(-5deg);transform:perspective(400px) rotateX(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}.flipInX{-webkit-backface-visibility:visible!important;backface-visibility:visible!important;-webkit-animation-name:flipInX;animation-name:flipInX}@-webkit-keyframes flipInY{0%{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);opacity:0}0%,40%{-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}40%{-webkit-transform:perspective(400px) rotateY(-20deg);transform:perspective(400px) rotateY(-20deg)}60%{-webkit-transform:perspective(400px) rotateY(10deg);transform:perspective(400px) rotateY(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateY(-5deg);transform:perspective(400px) rotateY(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}@keyframes flipInY{0%{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);opacity:0}0%,40%{-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}40%{-webkit-transform:perspective(400px) rotateY(-20deg);transform:perspective(400px) rotateY(-20deg)}60%{-webkit-transform:perspective(400px) rotateY(10deg);transform:perspective(400px) rotateY(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateY(-5deg);transform:perspective(400px) rotateY(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}.flipInY{-webkit-backface-visibility:visible!important;backface-visibility:visible!important;-webkit-animation-name:flipInY;animation-name:flipInY}@-webkit-keyframes flipOutX{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg);opacity:1}to{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);opacity:0}}@keyframes flipOutX{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg);opacity:1}to{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);opacity:0}}.flipOutX{-webkit-animation-name:flipOutX;animation-name:flipOutX;-webkit-backface-visibility:visible!important;backface-visibility:visible!important}@-webkit-keyframes flipOutY{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateY(-15deg);transform:perspective(400px) rotateY(-15deg);opacity:1}to{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);opacity:0}}@keyframes flipOutY{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateY(-15deg);transform:perspective(400px) rotateY(-15deg);opacity:1}to{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);opacity:0}}.flipOutY{-webkit-backface-visibility:visible!important;backface-visibility:visible!important;-webkit-animation-name:flipOutY;animation-name:flipOutY}@-webkit-keyframes lightSpeedIn{0%{-webkit-transform:translate3d(100%,0,0) skewX(-30deg);transform:translate3d(100%,0,0) skewX(-30deg);opacity:0}60%{-webkit-transform:skewX(20deg);transform:skewX(20deg)}60%,80%{opacity:1}80%{-webkit-transform:skewX(-5deg);transform:skewX(-5deg)}to{-webkit-transform:none;transform:none;opacity:1}}@keyframes lightSpeedIn{0%{-webkit-transform:translate3d(100%,0,0) skewX(-30deg);transform:translate3d(100%,0,0) skewX(-30deg);opacity:0}60%{-webkit-transform:skewX(20deg);transform:skewX(20deg)}60%,80%{opacity:1}80%{-webkit-transform:skewX(-5deg);transform:skewX(-5deg)}to{-webkit-transform:none;transform:none;opacity:1}}.lightSpeedIn{-webkit-animation-name:lightSpeedIn;animation-name:lightSpeedIn;-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}@-webkit-keyframes lightSpeedOut{0%{opacity:1}to{-webkit-transform:translate3d(100%,0,0) skewX(30deg);transform:translate3d(100%,0,0) skewX(30deg);opacity:0}}@keyframes lightSpeedOut{0%{opacity:1}to{-webkit-transform:translate3d(100%,0,0) skewX(30deg);transform:translate3d(100%,0,0) skewX(30deg);opacity:0}}.lightSpeedOut{-webkit-animation-name:lightSpeedOut;animation-name:lightSpeedOut;-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}@-webkit-keyframes rotateIn{0%{transform-origin:center;-webkit-transform:rotate(-200deg);transform:rotate(-200deg);opacity:0}0%,to{-webkit-transform-origin:center}to{transform-origin:center;-webkit-transform:none;transform:none;opacity:1}}@keyframes rotateIn{0%{transform-origin:center;-webkit-transform:rotate(-200deg);transform:rotate(-200deg);opacity:0}0%,to{-webkit-transform-origin:center}to{transform-origin:center;-webkit-transform:none;transform:none;opacity:1}}.rotateIn{-webkit-animation-name:rotateIn;animation-name:rotateIn}@-webkit-keyframes rotateInDownLeft{0%{transform-origin:left bottom;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}0%,to{-webkit-transform-origin:left bottom}to{transform-origin:left bottom;-webkit-transform:none;transform:none;opacity:1}}@keyframes rotateInDownLeft{0%{transform-origin:left bottom;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}0%,to{-webkit-transform-origin:left bottom}to{transform-origin:left bottom;-webkit-transform:none;transform:none;opacity:1}}.rotateInDownLeft{-webkit-animation-name:rotateInDownLeft;animation-name:rotateInDownLeft}@-webkit-keyframes rotateInDownRight{0%{transform-origin:right bottom;-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}0%,to{-webkit-transform-origin:right bottom}to{transform-origin:right bottom;-webkit-transform:none;transform:none;opacity:1}}@keyframes rotateInDownRight{0%{transform-origin:right bottom;-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}0%,to{-webkit-transform-origin:right bottom}to{transform-origin:right bottom;-webkit-transform:none;transform:none;opacity:1}}.rotateInDownRight{-webkit-animation-name:rotateInDownRight;animation-name:rotateInDownRight}@-webkit-keyframes rotateInUpLeft{0%{transform-origin:left bottom;-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}0%,to{-webkit-transform-origin:left bottom}to{transform-origin:left bottom;-webkit-transform:none;transform:none;opacity:1}}@keyframes rotateInUpLeft{0%{transform-origin:left bottom;-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}0%,to{-webkit-transform-origin:left bottom}to{transform-origin:left bottom;-webkit-transform:none;transform:none;opacity:1}}.rotateInUpLeft{-webkit-animation-name:rotateInUpLeft;animation-name:rotateInUpLeft}@-webkit-keyframes rotateInUpRight{0%{transform-origin:right bottom;-webkit-transform:rotate(-90deg);transform:rotate(-90deg);opacity:0}0%,to{-webkit-transform-origin:right bottom}to{transform-origin:right bottom;-webkit-transform:none;transform:none;opacity:1}}@keyframes rotateInUpRight{0%{transform-origin:right bottom;-webkit-transform:rotate(-90deg);transform:rotate(-90deg);opacity:0}0%,to{-webkit-transform-origin:right bottom}to{transform-origin:right bottom;-webkit-transform:none;transform:none;opacity:1}}.rotateInUpRight{-webkit-animation-name:rotateInUpRight;animation-name:rotateInUpRight}@-webkit-keyframes rotateOut{0%{transform-origin:center;opacity:1}0%,to{-webkit-transform-origin:center}to{transform-origin:center;-webkit-transform:rotate(200deg);transform:rotate(200deg);opacity:0}}@keyframes rotateOut{0%{transform-origin:center;opacity:1}0%,to{-webkit-transform-origin:center}to{transform-origin:center;-webkit-transform:rotate(200deg);transform:rotate(200deg);opacity:0}}.rotateOut{-webkit-animation-name:rotateOut;animation-name:rotateOut}@-webkit-keyframes rotateOutDownLeft{0%{transform-origin:left bottom;opacity:1}0%,to{-webkit-transform-origin:left bottom}to{transform-origin:left bottom;-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}}@keyframes rotateOutDownLeft{0%{transform-origin:left bottom;opacity:1}0%,to{-webkit-transform-origin:left bottom}to{transform-origin:left bottom;-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}}.rotateOutDownLeft{-webkit-animation-name:rotateOutDownLeft;animation-name:rotateOutDownLeft}@-webkit-keyframes rotateOutDownRight{0%{transform-origin:right bottom;opacity:1}0%,to{-webkit-transform-origin:right bottom}to{transform-origin:right bottom;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}@keyframes rotateOutDownRight{0%{transform-origin:right bottom;opacity:1}0%,to{-webkit-transform-origin:right bottom}to{transform-origin:right bottom;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}.rotateOutDownRight{-webkit-animation-name:rotateOutDownRight;animation-name:rotateOutDownRight}@-webkit-keyframes rotateOutUpLeft{0%{transform-origin:left bottom;opacity:1}0%,to{-webkit-transform-origin:left bottom}to{transform-origin:left bottom;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}@keyframes rotateOutUpLeft{0%{transform-origin:left bottom;opacity:1}0%,to{-webkit-transform-origin:left bottom}to{transform-origin:left bottom;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}.rotateOutUpLeft{-webkit-animation-name:rotateOutUpLeft;animation-name:rotateOutUpLeft}@-webkit-keyframes rotateOutUpRight{0%{transform-origin:right bottom;opacity:1}0%,to{-webkit-transform-origin:right bottom}to{transform-origin:right bottom;-webkit-transform:rotate(90deg);transform:rotate(90deg);opacity:0}}@keyframes rotateOutUpRight{0%{transform-origin:right bottom;opacity:1}0%,to{-webkit-transform-origin:right bottom}to{transform-origin:right bottom;-webkit-transform:rotate(90deg);transform:rotate(90deg);opacity:0}}.rotateOutUpRight{-webkit-animation-name:rotateOutUpRight;animation-name:rotateOutUpRight}@-webkit-keyframes hinge{0%{transform-origin:top left}0%,20%,60%{-webkit-transform-origin:top left;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}20%,60%{-webkit-transform:rotate(80deg);transform:rotate(80deg);transform-origin:top left}40%,80%{-webkit-transform:rotate(60deg);transform:rotate(60deg);-webkit-transform-origin:top left;transform-origin:top left;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;opacity:1}to{-webkit-transform:translate3d(0,700px,0);transform:translate3d(0,700px,0);opacity:0}}@keyframes hinge{0%{transform-origin:top left}0%,20%,60%{-webkit-transform-origin:top left;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}20%,60%{-webkit-transform:rotate(80deg);transform:rotate(80deg);transform-origin:top left}40%,80%{-webkit-transform:rotate(60deg);transform:rotate(60deg);-webkit-transform-origin:top left;transform-origin:top left;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;opacity:1}to{-webkit-transform:translate3d(0,700px,0);transform:translate3d(0,700px,0);opacity:0}}.hinge{-webkit-animation-name:hinge;animation-name:hinge}@-webkit-keyframes rollIn{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0) rotate(-120deg);transform:translate3d(-100%,0,0) rotate(-120deg)}to{opacity:1;-webkit-transform:none;transform:none}}@keyframes rollIn{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0) rotate(-120deg);transform:translate3d(-100%,0,0) rotate(-120deg)}to{opacity:1;-webkit-transform:none;transform:none}}.rollIn{-webkit-animation-name:rollIn;animation-name:rollIn}@-webkit-keyframes rollOut{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0) rotate(120deg);transform:translate3d(100%,0,0) rotate(120deg)}}@keyframes rollOut{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0) rotate(120deg);transform:translate3d(100%,0,0) rotate(120deg)}}.rollOut{-webkit-animation-name:rollOut;animation-name:rollOut}@-webkit-keyframes zoomIn{0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}50%{opacity:1}}@keyframes zoomIn{0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}50%{opacity:1}}.zoomIn{-webkit-animation-name:zoomIn;animation-name:zoomIn}@-webkit-keyframes zoomInDown{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInDown{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.zoomInDown{-webkit-animation-name:zoomInDown;animation-name:zoomInDown}@-webkit-keyframes zoomInLeft{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(10px,0,0);transform:scale3d(.475,.475,.475) translate3d(10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInLeft{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(10px,0,0);transform:scale3d(.475,.475,.475) translate3d(10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.zoomInLeft{-webkit-animation-name:zoomInLeft;animation-name:zoomInLeft}@-webkit-keyframes zoomInRight{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInRight{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.zoomInRight{-webkit-animation-name:zoomInRight;animation-name:zoomInRight}@-webkit-keyframes zoomInUp{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInUp{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.zoomInUp{-webkit-animation-name:zoomInUp;animation-name:zoomInUp}@-webkit-keyframes zoomOut{0%{opacity:1}50%{-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}50%,to{opacity:0}}@keyframes zoomOut{0%{opacity:1}50%{-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}50%,to{opacity:0}}.zoomOut{-webkit-animation-name:zoomOut;animation-name:zoomOut}@-webkit-keyframes zoomOutDown{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);-webkit-transform-origin:center bottom;transform-origin:center bottom;-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomOutDown{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);-webkit-transform-origin:center bottom;transform-origin:center bottom;-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.zoomOutDown{-webkit-animation-name:zoomOutDown;animation-name:zoomOutDown}@-webkit-keyframes zoomOutLeft{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(42px,0,0);transform:scale3d(.475,.475,.475) translate3d(42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(-2000px,0,0);transform:scale(.1) translate3d(-2000px,0,0);-webkit-transform-origin:left center;transform-origin:left center}}@keyframes zoomOutLeft{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(42px,0,0);transform:scale3d(.475,.475,.475) translate3d(42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(-2000px,0,0);transform:scale(.1) translate3d(-2000px,0,0);-webkit-transform-origin:left center;transform-origin:left center}}.zoomOutLeft{-webkit-animation-name:zoomOutLeft;animation-name:zoomOutLeft}@-webkit-keyframes zoomOutRight{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-42px,0,0);transform:scale3d(.475,.475,.475) translate3d(-42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(2000px,0,0);transform:scale(.1) translate3d(2000px,0,0);-webkit-transform-origin:right center;transform-origin:right center}}@keyframes zoomOutRight{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-42px,0,0);transform:scale3d(.475,.475,.475) translate3d(-42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(2000px,0,0);transform:scale(.1) translate3d(2000px,0,0);-webkit-transform-origin:right center;transform-origin:right center}}.zoomOutRight{-webkit-animation-name:zoomOutRight;animation-name:zoomOutRight}@-webkit-keyframes zoomOutUp{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);-webkit-transform-origin:center bottom;transform-origin:center bottom;-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomOutUp{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);-webkit-transform-origin:center bottom;transform-origin:center bottom;-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.zoomOutUp{-webkit-animation-name:zoomOutUp;animation-name:zoomOutUp}@-webkit-keyframes slideInDown{0%{-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInDown{0%{-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.slideInDown{-webkit-animation-name:slideInDown;animation-name:slideInDown}@-webkit-keyframes slideInLeft{0%{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInLeft{0%{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.slideInLeft{-webkit-animation-name:slideInLeft;animation-name:slideInLeft}@-webkit-keyframes slideInRight{0%{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInRight{0%{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.slideInRight{-webkit-animation-name:slideInRight;animation-name:slideInRight}@-webkit-keyframes slideInUp{0%{-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInUp{0%{-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.slideInUp{-webkit-animation-name:slideInUp;animation-name:slideInUp}@-webkit-keyframes slideOutDown{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}@keyframes slideOutDown{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}.slideOutDown{-webkit-animation-name:slideOutDown;animation-name:slideOutDown}@-webkit-keyframes slideOutLeft{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}@keyframes slideOutLeft{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}.slideOutLeft{-webkit-animation-name:slideOutLeft;animation-name:slideOutLeft}@-webkit-keyframes slideOutRight{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}@keyframes slideOutRight{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}.slideOutRight{-webkit-animation-name:slideOutRight;animation-name:slideOutRight}@-webkit-keyframes slideOutUp{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}@keyframes slideOutUp{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}.slideOutUp{-webkit-animation-name:slideOutUp;animation-name:slideOutUp} \ No newline at end of file diff --git a/html/allsky/controller.js b/html/allsky/controller.js new file mode 100755 index 000000000..aa15124e5 --- /dev/null +++ b/html/allsky/controller.js @@ -0,0 +1,759 @@ +var app = angular.module('allsky', ['ngLodash']); + +var overlayBuilt = false; // has the overlay been built yet? + +var virtualSkyData = null; +var sunData = "data.json"; // contains sunrise/sunset times and related data +var configData = "configuration.json"; // contains web configuration data +var dateTimeF = "YYYY-MM-DD HH:mm:ss"; +var dateF = "YYYY-MM-DD"; +var timeF = "HH:mm"; +var timeAmPmF = "h:mm a"; + +// This returns the height INCLUDING the border: $("#imageContainer").css('height') +// This returns the height NOT including the border: $("#imageContainer").height() + +// These two are used by virtualsky.js to set the overlay width and height, +// if there was a difference. +var overlayWidth = 0, overlayHeight = 0; +var overlayWidthMax = 0, overlayHeightMax = 0; +var starmapWidth = 0, starmapHeight = 0; +var wasDiff = true; +var last_s_iW = 0, last_s_iH = 0; +var icWidth = 0; +var icHeight = 0; +var icImageAspectRatio = 0; +var overlayAspectRatio = 0; +var myLatitude = 0, myLongitude = 0; + +$(window).resize(function () { + if (overlayBuilt) { // only rebuild if already built once + var newW = Math.round($("#imageContainer").width(), 0); + var newH = Math.round($("#imageContainer").height(), 0); + + $("#starmap_container").css("width", newW + "px").css("height", newH + "px"); + + var diffW = newW - icWidth; + // Scale the height based on the aspect ratio of the image. + var diffH = (newH - icHeight); + icWidth = newW; + icHeight = newH; + + if (diffW == 0 && diffH == 0) { + wasDiff = false; + console.log(">>> No change in image size."); + return; + } + + wasDiff = true; + + // Refresh the page if there was a difference + if (0 && wasDiff) { + location.reload(); + } + + // This holds the starmap button, so needs to resize + starmapWidth += diffW; + starmapHeight += diffH; + $("#starmap").css("width", starmapWidth + "px").css("height", starmapHeight + "px"); + + overlayWidth += diffW; + if (overlayWidth > overlayWidthMax) overlayWidth = overlayWidthMax; + overlayHeight += diffH; + if (overlayHeight > overlayHeightMax) overlayHeight = overlayHeightMax; + $("#starmap_inner") + .css("width", overlayWidth + "px") + .css("height", overlayHeight + "px"); + } +}); + +function buildOverlay(){ + if (overlayBuilt) { + S.virtualsky(virtualSkyData); + } else { + $.ajax({ + // No need for ?_ts= since $.ajax adds one + url: configData, + cache: false, + dataType: 'json', + error: function(jqXHR, textStatus, errorThrown) { + // TODO: Display the message on the screen. + if (jqXHR.status == 404) { + console.log(configData + " not found!"); + } else { + console.log("Error reading '" + configData + "': " + errorThrown); + } + }, + success: function (data) { + var c = data.config; + // "config" was defined in index.php to include ALL the variables we need, + // including ones not in the "config" section of the configuration file. + // However, "array" types like "colour" aren't handled in index.php. + + // TODO: I tried not doing the ajax call, but the overlay wouldn't show. + // It's a shame - there's no reason to re-read the file. + + virtualSkyData = c; + virtualSkyData.latitude = myLatitude; + virtualSkyData.longitude = myLongitude; + + // These variables have different names in virtualsky.js and our config file. + virtualSkyData.width = c.overlayWidth; + virtualSkyData.height = c.overlayHeight; + + S.virtualsky(virtualSkyData); // Creates overlay + overlayBuilt = true; + + // Save overlay offset values + overlayOffsetTop = c.overlayOffsetTop; + overlayOffsetLeft = c.overlayOffsetLeft; + + // max-width of #imageContainer is set in index.php based on + // width user specified (imageWidth) + icWidth = $("#imageContainer").width(); + icHeight = $("#imageContainer").height(); + icImageAspectRatio = icWidth / icHeight; + + $("#starmap_container") + .css("width", icWidth + "px") + .css("height", icHeight + "px"); + + overlayWidth = c.overlayWidth; + overlayHeight = c.overlayHeight; + overlayAspectRatio = overlayWidth / overlayHeight; + + // never go larger than what user specified + overlayHeightMax = overlayHeight; + overlayWidthMax = overlayWidth; + + starmapWidth = $("#starmap").width(); + starmapHeight = $("#starmap").height(); + + var imageWidth = c.imageWidth + if (icWidth < imageWidth) { + // The actual image on the screen is smaller than the + // imageWidth requested by the user. + // Determine the percent smaller, then shrink the overlay that amount. + var percentSmaller = icWidth / c.imageWidth; + + // #starmap holds the starmap button, so needs to resize it as well. + var w = starmapWidth * percentSmaller; + var h = Math.round(w / overlayAspectRatio, 0); + w = Math.round(w, 0); + $("#starmap") + .css("width", w + "px") + .css("height", h + "px"); + starmapWidth = w; + starmapHeight = h; + + // Offset of overlay + New Margins + var scalemargins = icWidth / overlayWidthMax; + $("#starmap") + .css("margin-top", c.overlayOffsetTop * scalemargins + "px") + .css("margin-left", c.overlayOffsetLeft * scalemargins + "px"); + + overlayWidth = Math.round(overlayWidth * percentSmaller, 0); + overlayHeight = Math.round(overlayWidth / overlayAspectRatio, 0); + $("#starmap_inner") + .css("width", overlayWidth + "px") + .css("height", overlayHeight + "px"); + } else { + $("#starmap") + .css("margin-top", c.overlayOffsetTop + "px") + .css("margin-left", Math.round(c.overlayOffsetLeft, 0) + "px"); + + } + + // id="live_container" is where the image goes. + var image_w = c.imageWidth; + var image_h = Math.round((image_w / icImageAspectRatio), 0); + + // Put "?" icon on upper right of image. +2 moves off border. + var x = w + - document.getElementById("imageContainer").offsetWidth + - document.getElementById("imageContainer").offsetLeft + + 2; // 2 to move off border + $(".starmap_btn_help").css("right", Math.round(x, 0) + "px"); + + // Keep track of the sizes. virtualsky.js seems to change them, + // so we need to change them based on our last known sizes. + last_s_iW = $("#starmap_inner").width(); + last_s_iH = $("#starmap_inner").height(); + } + }); + } +}; + +function compile($compile) { + // directive factory creates a link function + return function (scope, element, attrs) { + scope.$watch( + function (scope) { + // watch the 'compile' expression for changes + return scope.$eval(attrs.compile); + }, + function (value) { + // when the 'compile' expression changes + // assign it into the current DOM + element.html(value); + + // compile the new DOM and link it to the current + // scope. + // NOTE: we only compile .childNodes so that + // we don't get into infinite loop compiling ourselves + $compile(element.contents())(scope); + } + ); + }; +} + +function convertLatitude(sc, lat) { // sc == scope + var convertToString = false; + var len, direction; + + if (typeof lat === "string") { + sc.s_latitude = lat; // string version + + len = lat.length; + direction = lat.substr(len-1, 1).toUpperCase(); + if (direction == "N") + sc.latitude = lat.substr(0, len-2) * 1; + else if (direction == "S") + sc.latitude = lat.substr(0, len-2) * -1; + else { + // a number with quotes around it which is treated as a string + sc.latitude = lat * 1; + convertToString = true; + } + } else { + sc.latitude = lat; + convertToString = true; + } + + if (convertToString) { + if (lat >= 0) + sc.s_latitude = lat + "N"; + else + sc.s_latitude = -lat + "S"; + } + + return sc.latitude; +} + +function convertLongitude(sc, lon) { + var convertToString = false; + var len, direction; + + if (typeof lon === "string") { + sc.s_longitude = lon; + + len = config.longitude.length; + direction = lon.substr(len-1, 1).toUpperCase(); + if (direction == "E") + sc.longitude = lon.substr(0, len-2) * 1; + else if (direction == "W") + sc.longitude = lon.substr(0, len-2) * -1; + else { + // a number with quotes around it which is treated as a string + sc.longitude = lon * 1; + convertToString = true; + } + } else { + sc.longitude = lon; + convertToString = true; + } + + if (convertToString) { + if (config.longitude >= 0) + sc.s_longitude = lon + "E"; + else + sc.s_longitude = -lon + "W"; + } + + return sc.longitude; +} + +function AppCtrl($scope, $timeout, $http, _) { + + // Allow latitude and longitude to have or not have N, S, E, W, + // but in the popout, always use the letters for consistency. + // virtualsky.js expects decimal numbers so we need both. + // Need to convert them before building the overlay. + $scope.latitude = convertLatitude($scope, config.latitude); + myLatitude = $scope.latitude; + $scope.longitude = convertLongitude($scope, config.longitude); + myLongitude = $scope.longitude; + + $scope.imageURL = config.loadingImage; + $scope.showInfo = false; + $scope.showOverlay = config.showOverlayAtStartup; + if ($scope.showOverlay) { + console.log("@@ Building overlay at startup for showOverlay..."); + buildOverlay(); + } + $scope.notification = ""; + $scope.location = config.location; + $scope.camera = config.camera; + $scope.lens = config.lens; + $scope.computer = config.computer; + $scope.owner = config.owner; + $scope.auroraForecast = config.auroraForecast; + $scope.imageName = config.imageName; + $scope.AllskyVersion = config.AllskyVersion; + + function getHiddenProp() { + var prefixes = ['webkit', 'moz', 'ms', 'o']; + + // if 'hidden' is natively supported just return it + if ('hidden' in document) return 'hidden'; + + // otherwise loop over all the known prefixes until we find one + for (var i = 0; i < prefixes.length; i++) { + if ((prefixes[i] + 'Hidden') in document) + return prefixes[i] + 'Hidden'; + } + + // otherwise it's not supported + return null; + } + var hiddenProperty = getHiddenProp(); + + function isHidden() { + if (! hiddenProperty) return false; + return document[hiddenProperty]; + } + + // If the "sunData" file wasn't found, or for some reason "sunset" isn't in it, + // the routine that reads "sunData" will set "dataMissingMsg" so display it. + // If the file's old "dataOldMsg" will be set. + var dataMissingMsg = ""; + var dataOldMsg = ""; + + function formatMessage(msg, msgType) { + return("
" + msg + "
"); + } + + // How old should the data file be, or the sunset time be, in order to warn the user? + // In the morning before a new file is uploaded, + // it'll be a day old so use a value at least greater than 1. + const oldDataLimit = 2; + + // The defaultInterval should ideally be based on the time between day and night images - why + // check every 5 seconds if new images only appear once a minute? + var defaultInterval = (config.intervalSeconds * 1000); // Time to wait between normal images. + var intervalTimer = defaultInterval; // Amount of time we're currently waiting + + // If we're not taking pictures during the day, we don't need to check for updated images as often. + // If we're displaying an aurora picture, it's only updated every 5 mintutes. + // If we're not displaying an aurora picture the picture we ARE displaying doesn't change so + // there's no need to check until nightfall. + // However, in case the image DOES change, check every minute. Seems like a good compromise. + // Also, in both cases, if we wait too long, when the user returns to the web page after + // it's been hidden, they'll have to wait a long time for the page to update. + var auroraIntervalTimer = (60 * 1000); // seconds + var auroraIntervalTimerShortened = (15 * 1000); // seconds + var nonAuroraIntervalTimer = (60 * 1000); // seconds + + // When there is only this much time to nightime, + // shorten the timeout value for quicker message updates. + const startShortenedTimeout = (10 * 60 * 1000); // minutes + + var lastType = ""; + var loggedTimes = false; + var numImagesRead = 0; + var numCalls = 0; + + $scope.getImage = function () { + var url= ""; + var imageClass= ""; + // Go through the loop occassionally even when hidden so we re-read the sunData file + // if needed. + if (! isHidden() || ++numCalls % 5 == 0) { + $scope.notification = ""; + if (dataMissingMsg !== "") { + $scope.notification += formatMessage(dataMissingMsg, "error"); + } + if (dataOldMsg !== "") { + $scope.notification += formatMessage(dataOldMsg, "warning"); + } + + var rereadSunriseSunset = false; + + numImagesRead++; + + // the "m_" prefix means it's a moment() object. + var m_now = moment(new Date()); + var m_nowTime = m_now.format(timeF); + var m_sunriseTime = moment($scope.sunrise).format(timeF); + var m_sunsetTime = moment($scope.sunset).format(timeF); + var beforeSunriseTime = m_nowTime < m_sunriseTime; + var afterSunsetTime = m_nowTime > m_sunsetTime; + + // Check if the sunset time is too old. + // If the data file is old, don't bother checking sunset time since it'll be old too. + // However, we may need "daysOld" below so calculate it. + var m_nowDate = moment(m_now.format(dateF)); // needs to be moment() object + var m_sunsetDate = moment($scope.sunset.format(dateF)); + var daysOld = moment.duration(m_nowDate.diff(m_sunsetDate)).days(); + var oldMsg = ""; + + // This check assumes sunrise and sunset are both in the same day, + // which they should be since postData.sh runs at the end of nighttime and calculates + // sunrise and sunset for the current day. + + // It's nighttime if we're either before sunrise (e.g., 3 am and sunrise is 6 am) OR + // it's after sunset (e.g., 9 pm and sunset is 8 pm). + // Both only work if we're in the same day. + var is_nighttime; + if (beforeSunriseTime || afterSunsetTime) { + // sunrise is in the future so it's currently nighttime + is_nighttime = true; + } else { + is_nighttime = false; + } + + // The sunrise and sunset times change every day, and the user may have changed + // when they're taking images, so re-read the "sunData" file when something changes. + if (is_nighttime) { + // Only add to the console log once per message type + if (lastType !== "nighttime") { + console.log("=== Night Time imaging starts at " + m_now.format(timeAmPmF)); + lastType = "nighttime"; + loggedTimes = false; + rereadSunriseSunset = true; + } + url = config.imageName; + imageClass = 'current'; + intervalTimer = defaultInterval; + + } else if ($scope.takedaytimeimages) { + if (lastType !== "daytime") { + console.log("=== Day Time imaging starts at " + m_now.format(timeAmPmF)); + lastType = "daytime"; + loggedTimes = false; + rereadSunriseSunset = true; + } + url = config.imageName; + imageClass = 'current'; + intervalTimer = defaultInterval; + + } else { // daytime but we're not taking pictures + if (lastType !== "daytimeoff") { + console.log("=== Camera turned off during Day Time at " + m_now.format(timeAmPmF)); + lastType = "daytimeoff"; + loggedTimes = false; + rereadSunriseSunset = true; + } + + // Countdown calculation + // The sunset time only has hours and minutes so could be off by up to a minute, + // so add some time. Better to tell the user to come back in 2 minutes and + // have the actual time be 1 minute, than to tell them 1 minute and a new + // picture doesn't appear for 2 minutes after they return so they sit around waiting. + // Need to compare on the same date, but different times. + var ms = moment($scope.sunset, dateTimeF) + .add(daysOld,"days") + .diff(moment(m_now, dateTimeF)); + + // Testing showed that 1 minute wasn't enough to add, and we need to account for + // long nighttime exposures, so add 2.5 minutes. + const add = 2.5 * 60 * 1000; + ms += add; + const time_to_come_back = moment($scope.sunset + add).format(timeAmPmF); + + var d = moment.duration(ms); + var hours = Math.floor(d.asHours()); + var minutes = moment.utc(ms).format("m"); + var seconds = moment.utc(ms).format("s"); + var h = hours !== 0 ? hours + " hour" + (hours > 1 ? "s " : " ") : ""; + // Have to use != instead of !== because "minutes" is a string. + var m = minutes != 0 ? minutes + " minute" + (minutes > 1 ? "s" : "") : ""; + var s + if (hours == 0 && minutes == 0) + s = seconds + " seconds"; + else + s = h + m; + $scope.notification += formatMessage("It's not dark yet in " + config.location + ".    Come back at " + time_to_come_back + " (" + s + ").", "notice"); + + if (! loggedTimes) { + console.log("=== Resuming at nighttime in " + s); + } + if ($scope.auroraForecast) { + url = "https://services.swpc.noaa.gov/images/animations/ovation/"; + url += config.auroraMap + "/latest.jpg"; + imageClass = 'forecast-map'; + // If less than startShortenedTimeout time left, shorten the timer. + if (ms < startShortenedTimeout) { + intervalTimer = auroraIntervalTimerShortened; + } else { + intervalTimer = auroraIntervalTimer; + } + } else { + url = config.imageName; + imageClass = 'current'; + intervalTimer = nonAuroraIntervalTimer; + } + + } + + if (! loggedTimes) { // for debugging + loggedTimes = true; + console.log(" m_now = " + m_now.format(dateTimeF)); + if (oldMsg !== "") console.log(" > " + oldMsg); + + console.log(" m_now="+m_nowTime + ", m_sunrise="+m_sunriseTime + ", m_sunset="+m_sunsetTime); + console.log(" beforeSunriseTime = " + beforeSunriseTime); + console.log(" afterSunsetTime = " + afterSunsetTime); + } + +// TODO: Is there a way to specify not to cache this without using "?_ts" ? + var img = $("") + .attr('src', url + '?_ts=' + new Date().getTime()) + .addClass(imageClass) + .on('load', function() { + if (!this.complete || typeof this.naturalWidth === "undefined" || this.naturalWidth === 0) { + alert('broken image!'); + $timeout(function(){ + $scope.getImage(); + }, 500); + } else { + $("#live_container").empty().append(img); + } + }); + + // Don't re-read after the 1st image of this period since we read it right before the image. + if (rereadSunriseSunset && numImagesRead > 1) { + $scope.getSunRiseSet(); + } + } // if (! isHidden())) + }; + + // Set a default sunrise if we can't get it from "sunData". + var usingDefaultSunrise = false; + function getDefaultSunrise(today) { + return(moment(new Date(today.getFullYear(), today.getMonth(), today.getDate(), 6, 0, 0))); + } + // Set a default sunset if we can't get it from "sunData". + var usingDefaultSunset = false; + function getDefaultSunset(today) { + return(moment(new Date(today.getFullYear(), today.getMonth(), today.getDate(), 18, 0, 0))); + } + + function writeSunriseSunsetToConsole() { + console.log(" * sunrise = " + $scope.sunrise.format(dateTimeF) + + (usingDefaultSunrise ? " (default)" : "")); + console.log(" * sunset = " + $scope.sunset.format(dateTimeF) + + (usingDefaultSunset ? " (default)" : "")); + console.log(" * takedaytimeimages == " + $scope.takedaytimeimages); + console.log(" * takenighttimeimages == " + $scope.takenighttimeimages); + if (lastModifiedSunriseSunsetFile !== null) + console.log(" * last modified = " + lastModifiedSunriseSunsetFile.format(dateTimeF)); + } + + var usingDefaultTakingDaytime = false; + var usingDefaultTakingNighttime = false; + var lastModifiedSunriseSunsetFile = null; + + $scope.getSunRiseSet = function () { + now = new Date(); + var url = sunData; +// TODO: is ?_ts needed if we are not cache'ing ? + url += '?_ts=' + now.getTime(); + console.log("Read " + sunData + " on " + moment(now).format("MM-DD h:mm:ss a") + ":"); + $http.get(url, { + cache: false + }).then( + function (data) { + // Make sure all the data is there. + if (data.data.sunrise) { + $scope.sunrise = moment(data.data.sunrise); + usingDefaultSunrise = false; + } else if (! usingDefaultSunrise) { + $scope.sunrise = getDefaultSunrise(now); + usingDefaultSunrise = true; + } + if (data.data.sunset) { + $scope.sunset = moment(data.data.sunset); + usingDefaultSunset = false; + } else if (! usingDefaultSunset) { + $scope.sunset = getDefaultSunset(now); + usingDefaultSunset = true; + } + if (data.data.takedaytimeimages) { + $scope.takedaytimeimages = data.data.takedaytimeimages === "true"; +// TODO: streamDaytime is old name - delete in next release + } else if (data.data.streamDaytime) { + $scope.takedaytimeimages = data.data.streamDaytime === "true"; + } else { + $scope.takedaytimeimages = true; + usingDefaultTakingDaytime = true; + } + if (data.data.takenighttimeimages) { + $scope.takenighttimeimages = data.data.takenighttimeimages === "true"; + } else { + $scope.takenighttimeimages = true; + usingDefaultTakingNighttime = true; + } + + dataMissingMsg = ""; + if (usingDefaultSunset || usingDefaultSunrise || + usingDefaultTakingDaytime || usingDefaultTakingNighttime) { + dataMissingMsg = "ERROR: Data missing from '" + sunData + "':"; + dataMissingMsg += "
    "; + if (usingDefaultSunrise) { + dataMissingMsg += "
  • 'sunrise' (using " + $scope.sunrise.format(timeAmPmF) + ")"; + } + if (usingDefaultSunset) { + dataMissingMsg += "
  • 'sunset' (using " + $scope.sunset.format(timeAmPmF) + ")"; + } + if (usingDefaultTakingDaytime) { + dataMissingMsg += "
  • 'takedaytimeimages' (using " + $scope.takedaytimeimages + ")"; + } + if (usingDefaultTakingNighttime) { + dataMissingMsg += "
  • 'takenighttimeimages' (using " + $scope.takenighttimeimages + ")"; + } + dataMissingMsg += "
"; + dataMissingMsg += "Run 'postData.sh' to determine why data is missing."; + } + + // Get when the file was last modified so we can warn if it's old + function fetchHeader(url, wch) { + try { + var req=new XMLHttpRequest(); + req.open("HEAD", url, false); + req.send(null); + if(req.status == 200){ + return new Date(req.getResponseHeader(wch)); + } + else return false; + } catch(er) { + return er.message; + } + } + lastModifiedSunriseSunsetFile = null; + var x = fetchHeader(url,'Last-Modified'); + if (typeof x === "object") { // success - "x" is a Date object + lastModifiedSunriseSunsetFile = moment(x); + var duration = moment.duration(moment(now).diff(lastModifiedSunriseSunsetFile)); + if (duration.days() > oldDataLimit) { + dataOldMsg = "WARNING: '" + sunData + "' is " + duration.days() + " days old."; + if (dataMissingMsg == "") { + dataOldMsg += "
Check Allsky log file if 'postData.sh' has"; + dataOldMsg += " been running successfully at the end of nighttime."; + } + } + + } else { + console.log("fetchHeader(" + sunData + ") returned " + x); + } + + writeSunriseSunsetToConsole(); + + $scope.getImage() + + }, function() { + // Unable to read file. Set to defaults. + $scope.sunrise = getDefaultSunrise(now); usingDefaultSunrise = true; + $scope.sunset = getDefaultSunset(now); usingDefaultSunset = true; + $scope.takedaytimeimages = true;; usingDefaultTakingDaytime = true; + $scope.takenighttimeimages = true;; usingDefaultTakingDaytime = true; + + dataMissingMsg = "ERROR: '" + sunData + " file not found."; + dataMissingMsg += "
Using " + $scope.sunrise.format("h:mm a") + " for sunrise"; + dataMissingMsg += " and " + $scope.sunset.format("h:mm a") + " for sunset."; + dataMissingMsg += "
Run 'postData.sh' to create the file,"; + dataMissingMsg += " then refresh this browser window."; + console.log(" *** Unable to read '" + sunData + "' file"); + writeSunriseSunsetToConsole(); + + $scope.getImage() + } + ); + }; + $scope.getSunRiseSet(); + + $scope.intervalFunction = function () { + $timeout(function () { + $scope.getImage(); + $scope.intervalFunction(); + }, intervalTimer) + }; + $scope.intervalFunction(); + + $scope.toggleInfo = function () { + $scope.showInfo = !$scope.showInfo; + }; + + $scope.toggleOverlay = function () { + $scope.showOverlay = !$scope.showOverlay; + + if (! overlayBuilt && $scope.showOverlay) { + console.log("@@@@ Building overlay from toggle..."); + // Version 0.7.7 of VirtualSky doesn't show the overlay unless buildOverlay() is called. + buildOverlay(); + } + + $('.options').fadeToggle(); + $('#starmap_container').fadeToggle(); + }; + + // based mostly on https://auroraforecast.is/kp-index/ + $scope.getScale = function (index) { + var scale = { + 0: "Extremely_Quiet", + 1: "Very_Quiet", + 2: "Quiet", + 3: "Unsettled", + 4: "Active", + 5: "Minor_storm", + 6: "Moderate_storm", + 7: "Strong_storm", + 8: "Severe_storm", + 9: "Extreme_storm", + 100: "WARNING" + }; + return scale[index]; + }; + + if ($scope.auroraForecast) { + $scope.getForecast = function () { + + function getSum(data, field) { + var total = _.sumBy(data, function (row) { + return parseInt(row[field]); + }); + return Math.round(total / data.length); // return average + } + + function getDay(number) { + var day = moment().add(number, 'd'); + return moment(day).format("MMM") + " " + moment(day).format("DD"); + } + + $http.get("getForecast.php") + .then(function (response) { + $scope.forecast = {}; + // If the 1st 'time' value begins with "WARNING", there was an error getting data. + msg = response.data[0]['time']; + if ((msg.substring(0,9) == "WARNING: ") || response.data == "") { + // 100 indicates warning + $scope.forecast[''] = 100; // displays "WARNING" + $scope.forecast[msg.substring(9)] = -1; // displays msg + } else { + $scope.forecast[getDay(0)] = getSum(response.data, "day1"); + $scope.forecast[getDay(1)] = getSum(response.data, "day2"); + $scope.forecast[getDay(2)] = getSum(response.data, "day3"); + } + }); + }; + + $scope.getForecast(); + } +} + + +angular + .module('allsky') + .directive('compile', ['$compile', compile]) + .controller("AppCtrl", ['$scope', '$timeout', '$http', 'lodash', AppCtrl]) +; diff --git a/html/allsky/data.json b/html/allsky/data.json new file mode 100755 index 000000000..946be5473 --- /dev/null +++ b/html/allsky/data.json @@ -0,0 +1 @@ +{"sunset": "2016-05-27T00:24:00.000-0800"} diff --git a/html/allsky/fonts/allsky.eot b/html/allsky/fonts/allsky.eot new file mode 100755 index 000000000..30613075c Binary files /dev/null and b/html/allsky/fonts/allsky.eot differ diff --git a/html/allsky/fonts/allsky.svg b/html/allsky/fonts/allsky.svg new file mode 100755 index 000000000..85a014a57 --- /dev/null +++ b/html/allsky/fonts/allsky.svg @@ -0,0 +1,11 @@ + + + +Generated by IcoMoon + + + + + + + \ No newline at end of file diff --git a/html/allsky/fonts/allsky.ttf b/html/allsky/fonts/allsky.ttf new file mode 100755 index 000000000..f4f4d04ce Binary files /dev/null and b/html/allsky/fonts/allsky.ttf differ diff --git a/html/allsky/fonts/allsky.woff b/html/allsky/fonts/allsky.woff new file mode 100755 index 000000000..2e2505c42 Binary files /dev/null and b/html/allsky/fonts/allsky.woff differ diff --git a/html/allsky/fonts/mini-timelapse.eot b/html/allsky/fonts/mini-timelapse.eot new file mode 100644 index 000000000..9ab213bfb Binary files /dev/null and b/html/allsky/fonts/mini-timelapse.eot differ diff --git a/html/allsky/fonts/mini-timelapse.svg b/html/allsky/fonts/mini-timelapse.svg new file mode 100644 index 000000000..40cec60b5 --- /dev/null +++ b/html/allsky/fonts/mini-timelapse.svg @@ -0,0 +1,11 @@ + + + +Generated by IcoMoon + + + + + + + diff --git a/html/allsky/fonts/mini-timelapse.ttf b/html/allsky/fonts/mini-timelapse.ttf new file mode 100644 index 000000000..dfb5c8ae3 Binary files /dev/null and b/html/allsky/fonts/mini-timelapse.ttf differ diff --git a/html/allsky/fonts/mini-timelapse.woff b/html/allsky/fonts/mini-timelapse.woff new file mode 100644 index 000000000..100de7bea Binary files /dev/null and b/html/allsky/fonts/mini-timelapse.woff differ diff --git a/html/allsky/functions.php b/html/allsky/functions.php new file mode 100644 index 000000000..27be9336e --- /dev/null +++ b/html/allsky/functions.php @@ -0,0 +1,432 @@ +"; + $retMsg .= "ERROR: This remote Website does not appear to be fully installed."; + $retMsg .= "
The configuration file '$configuration_file' is missing."; + $retMsg .= "

Make sure this remote Website is installed enabled in the WebUI."; + $retMsg .= "

"; + return($retMsg); + } + $webSettings_str = file_get_contents($configuration_file, true); + if (strpos($webSettings_str, $needToUpdateString) !== false) { + $retMsg .= "

"; + $retMsg .= "The '$configurationFileName' file needs to be updated via"; + $retMsg .= " the 'Editor' page in the WebUI."; + $retMsg .= "

Update fields with '$needToUpdateString'"; + $retMsg .= " and check all other entries."; + $retMsg .= "

This Allsky Website will not work until updated."; + $retMsg .= "

"; + return($retMsg); + } + + $webSettings_array = json_decode($webSettings_str, true); + if ($webSettings_array == null) { + $retMsg .= "

"; + $retMsg .= "ERROR: Bad configuration file '$configurationFileName'."; + $retMsg .= "
Cannot continue."; + $retMsg .= "
Check for missing quotes or commas at the end of every line except the last one."; + $retMsg .= "

"; + $retMsg .= "
$webSettings_str
"; + return($retMsg); + } + + return(""); +} +$initializeErrorMessage = initialize(); +if ($initializeErrorMessage !== "" && $exitOnInitializationError) { + echo "$initializeErrorMessage"; + exit(1); +} + + +/* + * Look for $var in the $a array and return its value. + * If not found, return $default. + * If the value is a boolean and is false, an empty string is given to us so return 0; + * A true boolean value returns 1. +*/ +function v($var, $default, $a) { + if (isset($a[$var])) { + $value = $a[$var]; + if (gettype($default) === "boolean" && $value == "") + return(0); + else + return($value); + } else if (gettype($default) === "boolean") { + return(0); + } else { + return($default); + } +} + +/* + * Does the exec() function work? It's needed to make thumbnails from video files. +*/ +$yes_no = null; +function can_make_video_thumbnails() { + global $yes_no; + if ($yes_no !== null) return($yes_no); + + $disabled = explode(',', ini_get('disable_functions')); + // On some servers the disabled array contains leading spaces, so check both ways. + $exec_disabled = in_array('exec', $disabled) || in_array(' exec', $disabled); + + if ($exec_disabled) { + echo ""; + $yes_no = false; + } else { + // See if ffmpeg exists. + @exec("which ffmpeg 2> /dev/null", $ret, $retvalue); + if ($retvalue == 0) { + $yes_no = true; + } else { + echo ""; + $yes_no = false; + } + } + return($yes_no); +} + +/* + * Disable buffering. +*/ +function disableBuffering() { + ini_set('output_buffering', false); + ini_set('implicit_flush', true); + ob_implicit_flush(true); + for ($i = 0; $i < ob_get_level(); $i++) + ob_end_clean(); +} + +/** +* +* Get a variable from a file and return its value; if not there, return the default. +* NOTE: The variable's value is anything after the equal sign, so there shouldn't be a comment on the line. +* NOTE: There may be something before $searchfor, e.g., "export X=1", where "X" is $searchfor. +*/ +function get_variable($file, $searchfor, $default) +{ + // get the file contents + if (! file_exists($file)) return($default); + + $contents = file_get_contents($file); + if ("$contents" == "") return($default); // file not readable + + // escape special characters in the query + $pattern = preg_quote($searchfor, '/'); + // finalise the regular expression, matching the whole line + $pattern = "/^.*$pattern.*\$/m"; + + // search, and store all matching occurences in $matches, but only return the last one + $num_matches = preg_match_all($pattern, $contents, $matches); + if ($num_matches) { + $double_quote = '"'; + + // Format: [stuff]$searchfor=$value or [stuff]$searchfor="$value" + // Need to delete [stuff]$searchfor= and optional double quotes + $last = $matches[0][$num_matches - 1]; // get the last one + $both = explode( '=', $last); + if (isset($both[1])) { + $last = $both[1]; // everything after equal sign + $last = str_replace($double_quote, "", $last); + } else { + return($default); // nothing after "=" + } + return($last); + } else { + return($default); + } +} + +$displayed_thumbnail_error_message = false; +function make_thumb($src, $dest, $desired_width) +{ + if (! file_exists($src)) { + echo "

Unable to make thumbnail: '$src' does not exist!

"; + return(false); + } + if (filesize($src) === 0) { + echo "

Unable to make thumbnail: '$src' is empty! Removed it.

"; + unlink($src); + return(false); + } + + /* Make sure the imagecreatefromjpeg() function is in PHP. */ + global $displayed_thumbnail_error_message; + if ( preg_match("/\.(jpg|jpeg)$/", $src ) ) { + $funcext='jpeg'; + } elseif ( preg_match("/\.png$/", $src ) ) { + $funcext='png'; + } + if (function_exists("imagecreatefrom${funcext}") == false) + { + if ($displayed_thumbnail_error_message == false) + { + echo "

"; + echo "Unable to make thumbnail(s); imagecreatefrom{$funcext}() does not exist."; + echo "

"; + echo "If this is on a remote Allsky Website, ask the server administrator to"; + echo " support imagecreatefrom{$funcext}() in PHP."; + + echo "

"; + echo "If this is on a Pi and you do not have the file '/etc/php/8.2/mods-available/gd.ini'"; + echo " (or another php release), you need to download the latest PHP."; + echo "

"; + + $displayed_thumbnail_error_message = true; + } + return(false); + } + + /* read the source image */ + $funcname="imagecreatefrom{$funcext}"; + $source_image = $funcname($src); + $width = imagesx($source_image); + $height = imagesy($source_image); + + /* find the "desired height" of this thumbnail, relative to the desired width */ + if ($desired_width > $width) + $desired_width = $width; // This might create a very tall thumbnail... + $desired_height = floor($height * ($desired_width / $width)); + + /* create a new, "virtual" image */ + $virtual_image = imagecreatetruecolor($desired_width, $desired_height); + + /* copy source image at a resized size */ + imagecopyresampled($virtual_image, $source_image, 0, 0, 0, 0, $desired_width, $desired_height, $width, $height); + + /* create the physical thumbnail image to its destination */ + @imagejpeg($virtual_image, $dest); + + // flush so user sees thumbnails as they are created, instead of waiting for them all. + flush(); // flush even if we couldn't make the thumbnail so the user sees this file immediately. + if (file_exists($dest)) { + if (filesize($dest) === 0) { + echo "

Unable to make thumbnail for '$src': thumbnail was empty! Using full-size image for thumbnail.

"; + unlink($dest); + return(false); + } + return(true); + } else { + echo "

Unable to create thumbnail for '$src': " . error_get_last()['message'] . "

"; + return(false); + } +} + +// Did creation of last thumbnail work? +// If not, don't try to create any more since they likely won't work either. +$last_thumbnail_worked = true; + +// Similar to make_thumb() but using a video for the input file. +function make_thumb_from_video($src, $dest, $desired_width, $attempts) +{ + global $last_thumbnail_worked; + if (! $last_thumbnail_worked) { + return(false); + } + + if (! can_make_video_thumbnails()) { + return(false); + } + + if (! file_exists($src)) { + echo "

Unable to make thumbnail: '$src' does not exist!

"; + return(false); + } + if (filesize($src) === 0) { + echo "

Unable to make thumbnail: '$src' is empty! Removed it.

"; + unlink($src); + return(false); + } + + // Start 5 seconds in to skip any auto-exposure changes at the beginning. + // This of course assumes the video is at least 5 sec long. If it's not, we won't be able + // to create a thumbnail, so call ourselfs a second time using 1 second. + // If the file is less than 1 second long, well, too bad. + // "-1" scales the height to the original aspect ratio. + if ($attempts === 1) + $sec = "05"; + else + $sec = "00"; + $command = "ffmpeg -loglevel warning -ss 00:00:$sec -i '$src' -filter:v scale='$desired_width:-1' -frames:v 1 '$dest' 2>&1"; + $output = array(); + exec($command, $output); + if (file_exists($dest)) { + if (filesize($dest) === 0) { + echo "

Unable to make thumbnail for '$src': thumbnail was empty! Using full-size image for thumbnail.

"; + unlink($dest); + return(false); + } + return(true); + } + + if ($attempts >= 2) { + echo "

"; + echo "Failed to make thumbnail for " . basename($src) . " after $attempts attempts."; + echo "

If this is on a remote Allsky Website, enable the 'Upload Thumbnail' setting in"; + echo "the 'Timelapse Settings' section of the WebUI."; + echo "

"; + + echo ''; + echo ''; + + $last_thumbnail_worked = false; + return(false); + } + + return make_thumb_from_video($src, $dest, $desired_width, $attempts+1); +} + +// Display thumbnails with links to the full-size files +// for startrails, keograms, and videos. +// The function to make thumbnails for videos is different +$back_button = ""; +$back_button .= "  Back to Live View"; + +function display_thumbnails($dir, $file_prefix, $title) +{ + global $back_button, $webSettings_array, $thumbnailsortorder; + + if ($file_prefix === "allsky") { + $ext = "/\.(mp4|webm)$/"; + } else { + $ext = "/\.(jpg|jpeg|png)$/"; + } + $file_prefix_len = strlen($file_prefix); + + + $num_files = 0; + $files = array(); + if ($handle = opendir($dir)) { + while (false !== ($entry = readdir($handle))) { + if ( preg_match( $ext, $entry ) ) { + $files[] = $entry; + $num_files++; + } + } + closedir($handle); + } + if ($num_files == 0) { + echo "

$back_button

"; + echo "
No $title
"; + return; + } + + if ($thumbnailsortorder === "descending") { + arsort($files); + $sortOrder = "Sorted newest to oldest (descending)"; + } else { + asort($files); + $sortOrder = "Sorted oldest to newest (ascending)"; + } + $sortOrder = "$sortOrder"; + + $thumb_dir = "$dir/thumbnails"; + if (! is_dir($thumb_dir)) { + if (! mkdir($thumb_dir, 0775)) + echo "

Unable to make '$thumb_dir' directory. You will need to create it manually.

"; + print_r(error_get_last()); + } + + echo ""; + echo ""; + echo ""; + echo "
$back_button$title
"; + echo "
\n"; + + $thumbnailSizeX = v("thumbnailsizex", 100, $webSettings_array['homePage']); + foreach ($files as $file) { + // The thumbnail should be a .jpg. + $thumbnail = preg_replace($ext, ".jpg", "$dir/thumbnails/$file"); + if (! file_exists($thumbnail)) { + if ($file_prefix == "allsky") { + if (! make_thumb_from_video("$dir/$file", $thumbnail, $thumbnailSizeX, 1)) { + // We can't use the video file as a thumbnail + $thumbnail = "../NoThumbnail.png"; + } + } else { + if (! make_thumb("$dir/$file", $thumbnail, $thumbnailSizeX)) { + // Using the full-sized file as a thumbnail is overkill, + // but it's better than no thumbnail. + $thumbnail = "$dir/$file"; + } + } + // flush so user sees thumbnails as they are created, instead of waiting for them all. + //echo "
flushing after $file:"; + flush(); + } + $year = substr($file, $file_prefix_len + 1, 4); + $month = substr($file, $file_prefix_len + 5, 2); + $day = substr($file, $file_prefix_len + 7, 2); + $date = $year.$month.$day; + echo "
"; + echo "
"; + echo ""; + echo "
"; + echo "
$year-$month-$day
"; + echo "
\n"; + } + echo "
"; // archived-files + echo "
"; // clears "float" from archived-files + echo "

"; +} + +// Read and decode a json file, returning the decoded results or null. +// On error, display the specified error message +function get_decoded_json_file($file, $associative, $errorMsg) { + if (! file_exists($file)) { + echo "
"; + echo "$errorMsg:"; + echo "
File '$file' missing!"; + echo "
"; + return null; + } + + $str = file_get_contents($file, true); + if ($str === "") { + echo "
"; + echo "$errorMsg:"; + echo "
File '$file' is empty!"; + echo "
"; + return null; + } + + $str_array = json_decode($str, $associative); + if ($str_array == null) { + echo "
"; + echo "$errorMsg:"; + echo "
" . json_last_error_msg(); + $cmd = "json_pp < $file 2>&1"; + exec($cmd, $output); + echo "
" . implode("
", $output); + echo "
"; + return null; + } + return $str_array; +} + +?> diff --git a/html/allsky/getForecast.php b/html/allsky/getForecast.php new file mode 100755 index 000000000..addf57fb3 --- /dev/null +++ b/html/allsky/getForecast.php @@ -0,0 +1,28 @@ + $data) + { + //get row data + $noBrackets = preg_replace("/\([^)]+\)/","", $data); + $dataFormatted = preg_replace('!\s+!', ' ', $noBrackets); + $row_data = explode(' ', $dataFormatted); + $info[$row]['time'] = $row_data[0]; + $info[$row]['day1'] = $row_data[1]; + $info[$row]['day2'] = $row_data[2]; + $info[$row]['day3'] = $row_data[3]; + } +} else { + // The calling routine looks for "WARNING" in this field. + $info[0]['time'] = "WARNING: Unable to get data from '$url'"; +} +echo json_encode($info); +?> diff --git a/html/allsky/image.jpg b/html/allsky/image.jpg new file mode 100755 index 000000000..f01abd9cd Binary files /dev/null and b/html/allsky/image.jpg differ diff --git a/html/allsky/index.php b/html/allsky/index.php new file mode 100644 index 000000000..f24338988 --- /dev/null +++ b/html/allsky/index.php @@ -0,0 +1,236 @@ + + + + + + + + + + + + + + + + config = {\n"; + foreach ($config as $var => $val) { // ok to have comma after last entry + echo "\t\t$var: "; + if ($val === true || $val === false || $val === null || is_numeric($val)) { + echo var_export($val, true) . ",\n"; + } else if (is_array($val)) { + echo '"[array]",' . "\n"; + } else { + echo '"' . str_replace('"', '\"', $val) . '",' . "\n"; + } + } + // Add additional variable(s) from $homePage that are needed in controller.js. + echo "\t\timageBorder: $imageBorder,\n"; + echo "\t\ttitle: " . '"' . $title . '",' . "\n"; + echo "\t\tloadingImage: " . '"' . $loadingImage . '"'; + + echo "\n\t}"; + echo "\n\t\n"; + + } else { // initialization failed. +?> + Allsky Website + +

+

Allsky Website
+


$initializeErrorMessage\n"; ?> +

+ + + + <?php echo $title ?> + + + + + + + + + + + + + + + + + + +> +
+
+
+ Aurora activity: + {{key}}: + {{getScale(val)}} + +
+
+"; + if ($personalLink_prelink !== "") echo "$personalLink_prelink"; + echo "$personalLink_message"; + echo "
"; + } +?> + + + 0) { + echo "\t
\n"; + echo "\t\t
    \n"; + foreach ($popoutIcons as $popout) { + $display = v("display", false, $popout); + if (! $display) continue; + + $label = v("label", "", $popout); + $icon = v("icon", "", $popout); + $js_variable = v("variable", "", $popout); + $value = v("value", "", $popout); + $style = v("style", "", $popout); + if ($style != "") $style = "style='$style'"; + echo "\t\t\t
  •   $label:  "; + if ($js_variable != "") + echo "{{ $js_variable }}"; + else + echo "$value"; + echo "
  • \n"; + } + echo "\t\t
\n"; + echo "\t
\n"; + } +?> + + + + +
style="max-width: px"> +
+
+
+
+ allsky image +
+
+ +"; + echo "Build your own"; + echo ""; + } + + if ($includeGoogleAnalytics && file_exists("analyticsTracking.js")) { + echo ""; + } +?> + + diff --git a/html/allsky/keograms/index.php b/html/allsky/keograms/index.php new file mode 100644 index 000000000..7c255f1e7 --- /dev/null +++ b/html/allsky/keograms/index.php @@ -0,0 +1,6 @@ + diff --git a/html/allsky/loading.jpg b/html/allsky/loading.jpg new file mode 100644 index 000000000..3db52426c Binary files /dev/null and b/html/allsky/loading.jpg differ diff --git a/html/allsky/moment.js b/html/allsky/moment.js new file mode 100755 index 000000000..7299fa45c --- /dev/null +++ b/html/allsky/moment.js @@ -0,0 +1,3606 @@ +//! moment.js +//! version : 2.11.2 +//! authors : Tim Wood, Iskren Chernev, Moment.js contributors +//! license : MIT +//! momentjs.com + +;(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + global.moment = factory() +}(this, function () { 'use strict'; + + var hookCallback; + + function utils_hooks__hooks () { + return hookCallback.apply(null, arguments); + } + + // This is done to register the method called with moment() + // without creating circular dependencies. + function setHookCallback (callback) { + hookCallback = callback; + } + + function isArray(input) { + return Object.prototype.toString.call(input) === '[object Array]'; + } + + function isDate(input) { + return input instanceof Date || Object.prototype.toString.call(input) === '[object Date]'; + } + + function map(arr, fn) { + var res = [], i; + for (i = 0; i < arr.length; ++i) { + res.push(fn(arr[i], i)); + } + return res; + } + + function hasOwnProp(a, b) { + return Object.prototype.hasOwnProperty.call(a, b); + } + + function extend(a, b) { + for (var i in b) { + if (hasOwnProp(b, i)) { + a[i] = b[i]; + } + } + + if (hasOwnProp(b, 'toString')) { + a.toString = b.toString; + } + + if (hasOwnProp(b, 'valueOf')) { + a.valueOf = b.valueOf; + } + + return a; + } + + function create_utc__createUTC (input, format, locale, strict) { + return createLocalOrUTC(input, format, locale, strict, true).utc(); + } + + function defaultParsingFlags() { + // We need to deep clone this object. + return { + empty : false, + unusedTokens : [], + unusedInput : [], + overflow : -2, + charsLeftOver : 0, + nullInput : false, + invalidMonth : null, + invalidFormat : false, + userInvalidated : false, + iso : false + }; + } + + function getParsingFlags(m) { + if (m._pf == null) { + m._pf = defaultParsingFlags(); + } + return m._pf; + } + + function valid__isValid(m) { + if (m._isValid == null) { + var flags = getParsingFlags(m); + m._isValid = !isNaN(m._d.getTime()) && + flags.overflow < 0 && + !flags.empty && + !flags.invalidMonth && + !flags.invalidWeekday && + !flags.nullInput && + !flags.invalidFormat && + !flags.userInvalidated; + + if (m._strict) { + m._isValid = m._isValid && + flags.charsLeftOver === 0 && + flags.unusedTokens.length === 0 && + flags.bigHour === undefined; + } + } + return m._isValid; + } + + function valid__createInvalid (flags) { + var m = create_utc__createUTC(NaN); + if (flags != null) { + extend(getParsingFlags(m), flags); + } + else { + getParsingFlags(m).userInvalidated = true; + } + + return m; + } + + function isUndefined(input) { + return input === void 0; + } + + // Plugins that add properties should also add the key here (null value), + // so we can properly clone ourselves. + var momentProperties = utils_hooks__hooks.momentProperties = []; + + function copyConfig(to, from) { + var i, prop, val; + + if (!isUndefined(from._isAMomentObject)) { + to._isAMomentObject = from._isAMomentObject; + } + if (!isUndefined(from._i)) { + to._i = from._i; + } + if (!isUndefined(from._f)) { + to._f = from._f; + } + if (!isUndefined(from._l)) { + to._l = from._l; + } + if (!isUndefined(from._strict)) { + to._strict = from._strict; + } + if (!isUndefined(from._tzm)) { + to._tzm = from._tzm; + } + if (!isUndefined(from._isUTC)) { + to._isUTC = from._isUTC; + } + if (!isUndefined(from._offset)) { + to._offset = from._offset; + } + if (!isUndefined(from._pf)) { + to._pf = getParsingFlags(from); + } + if (!isUndefined(from._locale)) { + to._locale = from._locale; + } + + if (momentProperties.length > 0) { + for (i in momentProperties) { + prop = momentProperties[i]; + val = from[prop]; + if (!isUndefined(val)) { + to[prop] = val; + } + } + } + + return to; + } + + var updateInProgress = false; + + // Moment prototype object + function Moment(config) { + copyConfig(this, config); + this._d = new Date(config._d != null ? config._d.getTime() : NaN); + // Prevent infinite loop in case updateOffset creates new moment + // objects. + if (updateInProgress === false) { + updateInProgress = true; + utils_hooks__hooks.updateOffset(this); + updateInProgress = false; + } + } + + function isMoment (obj) { + return obj instanceof Moment || (obj != null && obj._isAMomentObject != null); + } + + function absFloor (number) { + if (number < 0) { + return Math.ceil(number); + } else { + return Math.floor(number); + } + } + + function toInt(argumentForCoercion) { + var coercedNumber = +argumentForCoercion, + value = 0; + + if (coercedNumber !== 0 && isFinite(coercedNumber)) { + value = absFloor(coercedNumber); + } + + return value; + } + + // compare two arrays, return the number of differences + function compareArrays(array1, array2, dontConvert) { + var len = Math.min(array1.length, array2.length), + lengthDiff = Math.abs(array1.length - array2.length), + diffs = 0, + i; + for (i = 0; i < len; i++) { + if ((dontConvert && array1[i] !== array2[i]) || + (!dontConvert && toInt(array1[i]) !== toInt(array2[i]))) { + diffs++; + } + } + return diffs + lengthDiff; + } + + function Locale() { + } + + // internal storage for locale config files + var locales = {}; + var globalLocale; + + function normalizeLocale(key) { + return key ? key.toLowerCase().replace('_', '-') : key; + } + + // pick the locale from the array + // try ['en-au', 'en-gb'] as 'en-au', 'en-gb', 'en', as in move through the list trying each + // substring from most specific to least, but move to the next array item if it's a more specific variant than the current root + function chooseLocale(names) { + var i = 0, j, next, locale, split; + + while (i < names.length) { + split = normalizeLocale(names[i]).split('-'); + j = split.length; + next = normalizeLocale(names[i + 1]); + next = next ? next.split('-') : null; + while (j > 0) { + locale = loadLocale(split.slice(0, j).join('-')); + if (locale) { + return locale; + } + if (next && next.length >= j && compareArrays(split, next, true) >= j - 1) { + //the next array item is better than a shallower substring of this one + break; + } + j--; + } + i++; + } + return null; + } + + function loadLocale(name) { + var oldLocale = null; + // TODO: Find a better way to register and load all the locales in Node + if (!locales[name] && (typeof module !== 'undefined') && + module && module.exports) { + try { + oldLocale = globalLocale._abbr; + require('./locale/' + name); + // because defineLocale currently also sets the global locale, we + // want to undo that for lazy loaded locales + locale_locales__getSetGlobalLocale(oldLocale); + } catch (e) { } + } + return locales[name]; + } + + // This function will load locale and then set the global locale. If + // no arguments are passed in, it will simply return the current global + // locale key. + function locale_locales__getSetGlobalLocale (key, values) { + var data; + if (key) { + if (isUndefined(values)) { + data = locale_locales__getLocale(key); + } + else { + data = defineLocale(key, values); + } + + if (data) { + // moment.duration._locale = moment._locale = data; + globalLocale = data; + } + } + + return globalLocale._abbr; + } + + function defineLocale (name, values) { + if (values !== null) { + values.abbr = name; + locales[name] = locales[name] || new Locale(); + locales[name].set(values); + + // backwards compat for now: also set the locale + locale_locales__getSetGlobalLocale(name); + + return locales[name]; + } else { + // useful for testing + delete locales[name]; + return null; + } + } + + // returns locale data + function locale_locales__getLocale (key) { + var locale; + + if (key && key._locale && key._locale._abbr) { + key = key._locale._abbr; + } + + if (!key) { + return globalLocale; + } + + if (!isArray(key)) { + //short-circuit everything else + locale = loadLocale(key); + if (locale) { + return locale; + } + key = [key]; + } + + return chooseLocale(key); + } + + var aliases = {}; + + function addUnitAlias (unit, shorthand) { + var lowerCase = unit.toLowerCase(); + aliases[lowerCase] = aliases[lowerCase + 's'] = aliases[shorthand] = unit; + } + + function normalizeUnits(units) { + return typeof units === 'string' ? aliases[units] || aliases[units.toLowerCase()] : undefined; + } + + function normalizeObjectUnits(inputObject) { + var normalizedInput = {}, + normalizedProp, + prop; + + for (prop in inputObject) { + if (hasOwnProp(inputObject, prop)) { + normalizedProp = normalizeUnits(prop); + if (normalizedProp) { + normalizedInput[normalizedProp] = inputObject[prop]; + } + } + } + + return normalizedInput; + } + + function isFunction(input) { + return input instanceof Function || Object.prototype.toString.call(input) === '[object Function]'; + } + + function makeGetSet (unit, keepTime) { + return function (value) { + if (value != null) { + get_set__set(this, unit, value); + utils_hooks__hooks.updateOffset(this, keepTime); + return this; + } else { + return get_set__get(this, unit); + } + }; + } + + function get_set__get (mom, unit) { + return mom.isValid() ? + mom._d['get' + (mom._isUTC ? 'UTC' : '') + unit]() : NaN; + } + + function get_set__set (mom, unit, value) { + if (mom.isValid()) { + mom._d['set' + (mom._isUTC ? 'UTC' : '') + unit](value); + } + } + + // MOMENTS + + function getSet (units, value) { + var unit; + if (typeof units === 'object') { + for (unit in units) { + this.set(unit, units[unit]); + } + } else { + units = normalizeUnits(units); + if (isFunction(this[units])) { + return this[units](value); + } + } + return this; + } + + function zeroFill(number, targetLength, forceSign) { + var absNumber = '' + Math.abs(number), + zerosToFill = targetLength - absNumber.length, + sign = number >= 0; + return (sign ? (forceSign ? '+' : '') : '-') + + Math.pow(10, Math.max(0, zerosToFill)).toString().substr(1) + absNumber; + } + + var formattingTokens = /(\[[^\[]*\])|(\\)?([Hh]mm(ss)?|Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Qo?|YYYYYY|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|mm?|ss?|S{1,9}|x|X|zz?|ZZ?|.)/g; + + var localFormattingTokens = /(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g; + + var formatFunctions = {}; + + var formatTokenFunctions = {}; + + // token: 'M' + // padded: ['MM', 2] + // ordinal: 'Mo' + // callback: function () { this.month() + 1 } + function addFormatToken (token, padded, ordinal, callback) { + var func = callback; + if (typeof callback === 'string') { + func = function () { + return this[callback](); + }; + } + if (token) { + formatTokenFunctions[token] = func; + } + if (padded) { + formatTokenFunctions[padded[0]] = function () { + return zeroFill(func.apply(this, arguments), padded[1], padded[2]); + }; + } + if (ordinal) { + formatTokenFunctions[ordinal] = function () { + return this.localeData().ordinal(func.apply(this, arguments), token); + }; + } + } + + function removeFormattingTokens(input) { + if (input.match(/\[[\s\S]/)) { + return input.replace(/^\[|\]$/g, ''); + } + return input.replace(/\\/g, ''); + } + + function makeFormatFunction(format) { + var array = format.match(formattingTokens), i, length; + + for (i = 0, length = array.length; i < length; i++) { + if (formatTokenFunctions[array[i]]) { + array[i] = formatTokenFunctions[array[i]]; + } else { + array[i] = removeFormattingTokens(array[i]); + } + } + + return function (mom) { + var output = ''; + for (i = 0; i < length; i++) { + output += array[i] instanceof Function ? array[i].call(mom, format) : array[i]; + } + return output; + }; + } + + // format date using native date object + function formatMoment(m, format) { + if (!m.isValid()) { + return m.localeData().invalidDate(); + } + + format = expandFormat(format, m.localeData()); + formatFunctions[format] = formatFunctions[format] || makeFormatFunction(format); + + return formatFunctions[format](m); + } + + function expandFormat(format, locale) { + var i = 5; + + function replaceLongDateFormatTokens(input) { + return locale.longDateFormat(input) || input; + } + + localFormattingTokens.lastIndex = 0; + while (i >= 0 && localFormattingTokens.test(format)) { + format = format.replace(localFormattingTokens, replaceLongDateFormatTokens); + localFormattingTokens.lastIndex = 0; + i -= 1; + } + + return format; + } + + var match1 = /\d/; // 0 - 9 + var match2 = /\d\d/; // 00 - 99 + var match3 = /\d{3}/; // 000 - 999 + var match4 = /\d{4}/; // 0000 - 9999 + var match6 = /[+-]?\d{6}/; // -999999 - 999999 + var match1to2 = /\d\d?/; // 0 - 99 + var match3to4 = /\d\d\d\d?/; // 999 - 9999 + var match5to6 = /\d\d\d\d\d\d?/; // 99999 - 999999 + var match1to3 = /\d{1,3}/; // 0 - 999 + var match1to4 = /\d{1,4}/; // 0 - 9999 + var match1to6 = /[+-]?\d{1,6}/; // -999999 - 999999 + + var matchUnsigned = /\d+/; // 0 - inf + var matchSigned = /[+-]?\d+/; // -inf - inf + + var matchOffset = /Z|[+-]\d\d:?\d\d/gi; // +00:00 -00:00 +0000 -0000 or Z + var matchShortOffset = /Z|[+-]\d\d(?::?\d\d)?/gi; // +00 -00 +00:00 -00:00 +0000 -0000 or Z + + var matchTimestamp = /[+-]?\d+(\.\d{1,3})?/; // 123456789 123456789.123 + + // any word (or two) characters or numbers including two/three word month in arabic. + // includes scottish gaelic two word and hyphenated months + var matchWord = /[0-9]*['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+|[\u0600-\u06FF\/]+(\s*?[\u0600-\u06FF]+){1,2}/i; + + + var regexes = {}; + + function addRegexToken (token, regex, strictRegex) { + regexes[token] = isFunction(regex) ? regex : function (isStrict, localeData) { + return (isStrict && strictRegex) ? strictRegex : regex; + }; + } + + function getParseRegexForToken (token, config) { + if (!hasOwnProp(regexes, token)) { + return new RegExp(unescapeFormat(token)); + } + + return regexes[token](config._strict, config._locale); + } + + // Code from http://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript + function unescapeFormat(s) { + return regexEscape(s.replace('\\', '').replace(/\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g, function (matched, p1, p2, p3, p4) { + return p1 || p2 || p3 || p4; + })); + } + + function regexEscape(s) { + return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + } + + var tokens = {}; + + function addParseToken (token, callback) { + var i, func = callback; + if (typeof token === 'string') { + token = [token]; + } + if (typeof callback === 'number') { + func = function (input, array) { + array[callback] = toInt(input); + }; + } + for (i = 0; i < token.length; i++) { + tokens[token[i]] = func; + } + } + + function addWeekParseToken (token, callback) { + addParseToken(token, function (input, array, config, token) { + config._w = config._w || {}; + callback(input, config._w, config, token); + }); + } + + function addTimeToArrayFromToken(token, input, config) { + if (input != null && hasOwnProp(tokens, token)) { + tokens[token](input, config._a, config, token); + } + } + + var YEAR = 0; + var MONTH = 1; + var DATE = 2; + var HOUR = 3; + var MINUTE = 4; + var SECOND = 5; + var MILLISECOND = 6; + var WEEK = 7; + var WEEKDAY = 8; + + function daysInMonth(year, month) { + return new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + } + + // FORMATTING + + addFormatToken('M', ['MM', 2], 'Mo', function () { + return this.month() + 1; + }); + + addFormatToken('MMM', 0, 0, function (format) { + return this.localeData().monthsShort(this, format); + }); + + addFormatToken('MMMM', 0, 0, function (format) { + return this.localeData().months(this, format); + }); + + // ALIASES + + addUnitAlias('month', 'M'); + + // PARSING + + addRegexToken('M', match1to2); + addRegexToken('MM', match1to2, match2); + addRegexToken('MMM', function (isStrict, locale) { + return locale.monthsShortRegex(isStrict); + }); + addRegexToken('MMMM', function (isStrict, locale) { + return locale.monthsRegex(isStrict); + }); + + addParseToken(['M', 'MM'], function (input, array) { + array[MONTH] = toInt(input) - 1; + }); + + addParseToken(['MMM', 'MMMM'], function (input, array, config, token) { + var month = config._locale.monthsParse(input, token, config._strict); + // if we didn't find a month name, mark the date as invalid. + if (month != null) { + array[MONTH] = month; + } else { + getParsingFlags(config).invalidMonth = input; + } + }); + + // LOCALES + + var MONTHS_IN_FORMAT = /D[oD]?(\[[^\[\]]*\]|\s+)+MMMM?/; + var defaultLocaleMonths = 'January_February_March_April_May_June_July_August_September_October_November_December'.split('_'); + function localeMonths (m, format) { + return isArray(this._months) ? this._months[m.month()] : + this._months[MONTHS_IN_FORMAT.test(format) ? 'format' : 'standalone'][m.month()]; + } + + var defaultLocaleMonthsShort = 'Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec'.split('_'); + function localeMonthsShort (m, format) { + return isArray(this._monthsShort) ? this._monthsShort[m.month()] : + this._monthsShort[MONTHS_IN_FORMAT.test(format) ? 'format' : 'standalone'][m.month()]; + } + + function localeMonthsParse (monthName, format, strict) { + var i, mom, regex; + + if (!this._monthsParse) { + this._monthsParse = []; + this._longMonthsParse = []; + this._shortMonthsParse = []; + } + + for (i = 0; i < 12; i++) { + // make the regex if we don't have it already + mom = create_utc__createUTC([2000, i]); + if (strict && !this._longMonthsParse[i]) { + this._longMonthsParse[i] = new RegExp('^' + this.months(mom, '').replace('.', '') + '$', 'i'); + this._shortMonthsParse[i] = new RegExp('^' + this.monthsShort(mom, '').replace('.', '') + '$', 'i'); + } + if (!strict && !this._monthsParse[i]) { + regex = '^' + this.months(mom, '') + '|^' + this.monthsShort(mom, ''); + this._monthsParse[i] = new RegExp(regex.replace('.', ''), 'i'); + } + // test the regex + if (strict && format === 'MMMM' && this._longMonthsParse[i].test(monthName)) { + return i; + } else if (strict && format === 'MMM' && this._shortMonthsParse[i].test(monthName)) { + return i; + } else if (!strict && this._monthsParse[i].test(monthName)) { + return i; + } + } + } + + // MOMENTS + + function setMonth (mom, value) { + var dayOfMonth; + + if (!mom.isValid()) { + // No op + return mom; + } + + // TODO: Move this out of here! + if (typeof value === 'string') { + value = mom.localeData().monthsParse(value); + // TODO: Another silent failure? + if (typeof value !== 'number') { + return mom; + } + } + + dayOfMonth = Math.min(mom.date(), daysInMonth(mom.year(), value)); + mom._d['set' + (mom._isUTC ? 'UTC' : '') + 'Month'](value, dayOfMonth); + return mom; + } + + function getSetMonth (value) { + if (value != null) { + setMonth(this, value); + utils_hooks__hooks.updateOffset(this, true); + return this; + } else { + return get_set__get(this, 'Month'); + } + } + + function getDaysInMonth () { + return daysInMonth(this.year(), this.month()); + } + + var defaultMonthsShortRegex = matchWord; + function monthsShortRegex (isStrict) { + if (this._monthsParseExact) { + if (!hasOwnProp(this, '_monthsRegex')) { + computeMonthsParse.call(this); + } + if (isStrict) { + return this._monthsShortStrictRegex; + } else { + return this._monthsShortRegex; + } + } else { + return this._monthsShortStrictRegex && isStrict ? + this._monthsShortStrictRegex : this._monthsShortRegex; + } + } + + var defaultMonthsRegex = matchWord; + function monthsRegex (isStrict) { + if (this._monthsParseExact) { + if (!hasOwnProp(this, '_monthsRegex')) { + computeMonthsParse.call(this); + } + if (isStrict) { + return this._monthsStrictRegex; + } else { + return this._monthsRegex; + } + } else { + return this._monthsStrictRegex && isStrict ? + this._monthsStrictRegex : this._monthsRegex; + } + } + + function computeMonthsParse () { + function cmpLenRev(a, b) { + return b.length - a.length; + } + + var shortPieces = [], longPieces = [], mixedPieces = [], + i, mom; + for (i = 0; i < 12; i++) { + // make the regex if we don't have it already + mom = create_utc__createUTC([2000, i]); + shortPieces.push(this.monthsShort(mom, '')); + longPieces.push(this.months(mom, '')); + mixedPieces.push(this.months(mom, '')); + mixedPieces.push(this.monthsShort(mom, '')); + } + // Sorting makes sure if one month (or abbr) is a prefix of another it + // will match the longer piece. + shortPieces.sort(cmpLenRev); + longPieces.sort(cmpLenRev); + mixedPieces.sort(cmpLenRev); + for (i = 0; i < 12; i++) { + shortPieces[i] = regexEscape(shortPieces[i]); + longPieces[i] = regexEscape(longPieces[i]); + mixedPieces[i] = regexEscape(mixedPieces[i]); + } + + this._monthsRegex = new RegExp('^(' + mixedPieces.join('|') + ')', 'i'); + this._monthsShortRegex = this._monthsRegex; + this._monthsStrictRegex = new RegExp('^(' + longPieces.join('|') + ')$', 'i'); + this._monthsShortStrictRegex = new RegExp('^(' + shortPieces.join('|') + ')$', 'i'); + } + + function checkOverflow (m) { + var overflow; + var a = m._a; + + if (a && getParsingFlags(m).overflow === -2) { + overflow = + a[MONTH] < 0 || a[MONTH] > 11 ? MONTH : + a[DATE] < 1 || a[DATE] > daysInMonth(a[YEAR], a[MONTH]) ? DATE : + a[HOUR] < 0 || a[HOUR] > 24 || (a[HOUR] === 24 && (a[MINUTE] !== 0 || a[SECOND] !== 0 || a[MILLISECOND] !== 0)) ? HOUR : + a[MINUTE] < 0 || a[MINUTE] > 59 ? MINUTE : + a[SECOND] < 0 || a[SECOND] > 59 ? SECOND : + a[MILLISECOND] < 0 || a[MILLISECOND] > 999 ? MILLISECOND : + -1; + + if (getParsingFlags(m)._overflowDayOfYear && (overflow < YEAR || overflow > DATE)) { + overflow = DATE; + } + if (getParsingFlags(m)._overflowWeeks && overflow === -1) { + overflow = WEEK; + } + if (getParsingFlags(m)._overflowWeekday && overflow === -1) { + overflow = WEEKDAY; + } + + getParsingFlags(m).overflow = overflow; + } + + return m; + } + + function warn(msg) { + if (utils_hooks__hooks.suppressDeprecationWarnings === false && + (typeof console !== 'undefined') && console.warn) { + console.warn('Deprecation warning: ' + msg); + } + } + + function deprecate(msg, fn) { + var firstTime = true; + + return extend(function () { + if (firstTime) { + warn(msg + '\nArguments: ' + Array.prototype.slice.call(arguments).join(', ') + '\n' + (new Error()).stack); + firstTime = false; + } + return fn.apply(this, arguments); + }, fn); + } + + var deprecations = {}; + + function deprecateSimple(name, msg) { + if (!deprecations[name]) { + warn(msg); + deprecations[name] = true; + } + } + + utils_hooks__hooks.suppressDeprecationWarnings = false; + + // iso 8601 regex + // 0000-00-00 0000-W00 or 0000-W00-0 + T + 00 or 00:00 or 00:00:00 or 00:00:00.000 + +00:00 or +0000 or +00) + var extendedIsoRegex = /^\s*((?:[+-]\d{6}|\d{4})-(?:\d\d-\d\d|W\d\d-\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?::\d\d(?::\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?/; + var basicIsoRegex = /^\s*((?:[+-]\d{6}|\d{4})(?:\d\d\d\d|W\d\d\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?:\d\d(?:\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?/; + + var tzRegex = /Z|[+-]\d\d(?::?\d\d)?/; + + var isoDates = [ + ['YYYYYY-MM-DD', /[+-]\d{6}-\d\d-\d\d/], + ['YYYY-MM-DD', /\d{4}-\d\d-\d\d/], + ['GGGG-[W]WW-E', /\d{4}-W\d\d-\d/], + ['GGGG-[W]WW', /\d{4}-W\d\d/, false], + ['YYYY-DDD', /\d{4}-\d{3}/], + ['YYYY-MM', /\d{4}-\d\d/, false], + ['YYYYYYMMDD', /[+-]\d{10}/], + ['YYYYMMDD', /\d{8}/], + // YYYYMM is NOT allowed by the standard + ['GGGG[W]WWE', /\d{4}W\d{3}/], + ['GGGG[W]WW', /\d{4}W\d{2}/, false], + ['YYYYDDD', /\d{7}/] + ]; + + // iso time formats and regexes + var isoTimes = [ + ['HH:mm:ss.SSSS', /\d\d:\d\d:\d\d\.\d+/], + ['HH:mm:ss,SSSS', /\d\d:\d\d:\d\d,\d+/], + ['HH:mm:ss', /\d\d:\d\d:\d\d/], + ['HH:mm', /\d\d:\d\d/], + ['HHmmss.SSSS', /\d\d\d\d\d\d\.\d+/], + ['HHmmss,SSSS', /\d\d\d\d\d\d,\d+/], + ['HHmmss', /\d\d\d\d\d\d/], + ['HHmm', /\d\d\d\d/], + ['HH', /\d\d/] + ]; + + var aspNetJsonRegex = /^\/?Date\((\-?\d+)/i; + + // date from iso format + function configFromISO(config) { + var i, l, + string = config._i, + match = extendedIsoRegex.exec(string) || basicIsoRegex.exec(string), + allowTime, dateFormat, timeFormat, tzFormat; + + if (match) { + getParsingFlags(config).iso = true; + + for (i = 0, l = isoDates.length; i < l; i++) { + if (isoDates[i][1].exec(match[1])) { + dateFormat = isoDates[i][0]; + allowTime = isoDates[i][2] !== false; + break; + } + } + if (dateFormat == null) { + config._isValid = false; + return; + } + if (match[3]) { + for (i = 0, l = isoTimes.length; i < l; i++) { + if (isoTimes[i][1].exec(match[3])) { + // match[2] should be 'T' or space + timeFormat = (match[2] || ' ') + isoTimes[i][0]; + break; + } + } + if (timeFormat == null) { + config._isValid = false; + return; + } + } + if (!allowTime && timeFormat != null) { + config._isValid = false; + return; + } + if (match[4]) { + if (tzRegex.exec(match[4])) { + tzFormat = 'Z'; + } else { + config._isValid = false; + return; + } + } + config._f = dateFormat + (timeFormat || '') + (tzFormat || ''); + configFromStringAndFormat(config); + } else { + config._isValid = false; + } + } + + // date from iso format or fallback + function configFromString(config) { + var matched = aspNetJsonRegex.exec(config._i); + + if (matched !== null) { + config._d = new Date(+matched[1]); + return; + } + + configFromISO(config); + if (config._isValid === false) { + delete config._isValid; + utils_hooks__hooks.createFromInputFallback(config); + } + } + + utils_hooks__hooks.createFromInputFallback = deprecate( + 'moment construction falls back to js Date. This is ' + + 'discouraged and will be removed in upcoming major ' + + 'release. Please refer to ' + + 'https://github.com/moment/moment/issues/1407 for more info.', + function (config) { + config._d = new Date(config._i + (config._useUTC ? ' UTC' : '')); + } + ); + + function createDate (y, m, d, h, M, s, ms) { + //can't just apply() to create a date: + //http://stackoverflow.com/questions/181348/instantiating-a-javascript-object-by-calling-prototype-constructor-apply + var date = new Date(y, m, d, h, M, s, ms); + + //the date constructor remaps years 0-99 to 1900-1999 + if (y < 100 && y >= 0 && isFinite(date.getFullYear())) { + date.setFullYear(y); + } + return date; + } + + function createUTCDate (y) { + var date = new Date(Date.UTC.apply(null, arguments)); + + //the Date.UTC function remaps years 0-99 to 1900-1999 + if (y < 100 && y >= 0 && isFinite(date.getUTCFullYear())) { + date.setUTCFullYear(y); + } + return date; + } + + // FORMATTING + + addFormatToken('Y', 0, 0, function () { + var y = this.year(); + return y <= 9999 ? '' + y : '+' + y; + }); + + addFormatToken(0, ['YY', 2], 0, function () { + return this.year() % 100; + }); + + addFormatToken(0, ['YYYY', 4], 0, 'year'); + addFormatToken(0, ['YYYYY', 5], 0, 'year'); + addFormatToken(0, ['YYYYYY', 6, true], 0, 'year'); + + // ALIASES + + addUnitAlias('year', 'y'); + + // PARSING + + addRegexToken('Y', matchSigned); + addRegexToken('YY', match1to2, match2); + addRegexToken('YYYY', match1to4, match4); + addRegexToken('YYYYY', match1to6, match6); + addRegexToken('YYYYYY', match1to6, match6); + + addParseToken(['YYYYY', 'YYYYYY'], YEAR); + addParseToken('YYYY', function (input, array) { + array[YEAR] = input.length === 2 ? utils_hooks__hooks.parseTwoDigitYear(input) : toInt(input); + }); + addParseToken('YY', function (input, array) { + array[YEAR] = utils_hooks__hooks.parseTwoDigitYear(input); + }); + addParseToken('Y', function (input, array) { + array[YEAR] = parseInt(input, 10); + }); + + // HELPERS + + function daysInYear(year) { + return isLeapYear(year) ? 366 : 365; + } + + function isLeapYear(year) { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + } + + // HOOKS + + utils_hooks__hooks.parseTwoDigitYear = function (input) { + return toInt(input) + (toInt(input) > 68 ? 1900 : 2000); + }; + + // MOMENTS + + var getSetYear = makeGetSet('FullYear', false); + + function getIsLeapYear () { + return isLeapYear(this.year()); + } + + // start-of-first-week - start-of-year + function firstWeekOffset(year, dow, doy) { + var // first-week day -- which january is always in the first week (4 for iso, 1 for other) + fwd = 7 + dow - doy, + // first-week day local weekday -- which local weekday is fwd + fwdlw = (7 + createUTCDate(year, 0, fwd).getUTCDay() - dow) % 7; + + return -fwdlw + fwd - 1; + } + + //http://en.wikipedia.org/wiki/ISO_week_date#Calculating_a_date_given_the_year.2C_week_number_and_weekday + function dayOfYearFromWeeks(year, week, weekday, dow, doy) { + var localWeekday = (7 + weekday - dow) % 7, + weekOffset = firstWeekOffset(year, dow, doy), + dayOfYear = 1 + 7 * (week - 1) + localWeekday + weekOffset, + resYear, resDayOfYear; + + if (dayOfYear <= 0) { + resYear = year - 1; + resDayOfYear = daysInYear(resYear) + dayOfYear; + } else if (dayOfYear > daysInYear(year)) { + resYear = year + 1; + resDayOfYear = dayOfYear - daysInYear(year); + } else { + resYear = year; + resDayOfYear = dayOfYear; + } + + return { + year: resYear, + dayOfYear: resDayOfYear + }; + } + + function weekOfYear(mom, dow, doy) { + var weekOffset = firstWeekOffset(mom.year(), dow, doy), + week = Math.floor((mom.dayOfYear() - weekOffset - 1) / 7) + 1, + resWeek, resYear; + + if (week < 1) { + resYear = mom.year() - 1; + resWeek = week + weeksInYear(resYear, dow, doy); + } else if (week > weeksInYear(mom.year(), dow, doy)) { + resWeek = week - weeksInYear(mom.year(), dow, doy); + resYear = mom.year() + 1; + } else { + resYear = mom.year(); + resWeek = week; + } + + return { + week: resWeek, + year: resYear + }; + } + + function weeksInYear(year, dow, doy) { + var weekOffset = firstWeekOffset(year, dow, doy), + weekOffsetNext = firstWeekOffset(year + 1, dow, doy); + return (daysInYear(year) - weekOffset + weekOffsetNext) / 7; + } + + // Pick the first defined of two or three arguments. + function defaults(a, b, c) { + if (a != null) { + return a; + } + if (b != null) { + return b; + } + return c; + } + + function currentDateArray(config) { + // hooks is actually the exported moment object + var nowValue = new Date(utils_hooks__hooks.now()); + if (config._useUTC) { + return [nowValue.getUTCFullYear(), nowValue.getUTCMonth(), nowValue.getUTCDate()]; + } + return [nowValue.getFullYear(), nowValue.getMonth(), nowValue.getDate()]; + } + + // convert an array to a date. + // the array should mirror the parameters below + // note: all values past the year are optional and will default to the lowest possible value. + // [year, month, day , hour, minute, second, millisecond] + function configFromArray (config) { + var i, date, input = [], currentDate, yearToUse; + + if (config._d) { + return; + } + + currentDate = currentDateArray(config); + + //compute day of the year from weeks and weekdays + if (config._w && config._a[DATE] == null && config._a[MONTH] == null) { + dayOfYearFromWeekInfo(config); + } + + //if the day of the year is set, figure out what it is + if (config._dayOfYear) { + yearToUse = defaults(config._a[YEAR], currentDate[YEAR]); + + if (config._dayOfYear > daysInYear(yearToUse)) { + getParsingFlags(config)._overflowDayOfYear = true; + } + + date = createUTCDate(yearToUse, 0, config._dayOfYear); + config._a[MONTH] = date.getUTCMonth(); + config._a[DATE] = date.getUTCDate(); + } + + // Default to current date. + // * if no year, month, day of month are given, default to today + // * if day of month is given, default month and year + // * if month is given, default only year + // * if year is given, don't default anything + for (i = 0; i < 3 && config._a[i] == null; ++i) { + config._a[i] = input[i] = currentDate[i]; + } + + // Zero out whatever was not defaulted, including time + for (; i < 7; i++) { + config._a[i] = input[i] = (config._a[i] == null) ? (i === 2 ? 1 : 0) : config._a[i]; + } + + // Check for 24:00:00.000 + if (config._a[HOUR] === 24 && + config._a[MINUTE] === 0 && + config._a[SECOND] === 0 && + config._a[MILLISECOND] === 0) { + config._nextDay = true; + config._a[HOUR] = 0; + } + + config._d = (config._useUTC ? createUTCDate : createDate).apply(null, input); + // Apply timezone offset from input. The actual utcOffset can be changed + // with parseZone. + if (config._tzm != null) { + config._d.setUTCMinutes(config._d.getUTCMinutes() - config._tzm); + } + + if (config._nextDay) { + config._a[HOUR] = 24; + } + } + + function dayOfYearFromWeekInfo(config) { + var w, weekYear, week, weekday, dow, doy, temp, weekdayOverflow; + + w = config._w; + if (w.GG != null || w.W != null || w.E != null) { + dow = 1; + doy = 4; + + // TODO: We need to take the current isoWeekYear, but that depends on + // how we interpret now (local, utc, fixed offset). So create + // a now version of current config (take local/utc/offset flags, and + // create now). + weekYear = defaults(w.GG, config._a[YEAR], weekOfYear(local__createLocal(), 1, 4).year); + week = defaults(w.W, 1); + weekday = defaults(w.E, 1); + if (weekday < 1 || weekday > 7) { + weekdayOverflow = true; + } + } else { + dow = config._locale._week.dow; + doy = config._locale._week.doy; + + weekYear = defaults(w.gg, config._a[YEAR], weekOfYear(local__createLocal(), dow, doy).year); + week = defaults(w.w, 1); + + if (w.d != null) { + // weekday -- low day numbers are considered next week + weekday = w.d; + if (weekday < 0 || weekday > 6) { + weekdayOverflow = true; + } + } else if (w.e != null) { + // local weekday -- counting starts from begining of week + weekday = w.e + dow; + if (w.e < 0 || w.e > 6) { + weekdayOverflow = true; + } + } else { + // default to begining of week + weekday = dow; + } + } + if (week < 1 || week > weeksInYear(weekYear, dow, doy)) { + getParsingFlags(config)._overflowWeeks = true; + } else if (weekdayOverflow != null) { + getParsingFlags(config)._overflowWeekday = true; + } else { + temp = dayOfYearFromWeeks(weekYear, week, weekday, dow, doy); + config._a[YEAR] = temp.year; + config._dayOfYear = temp.dayOfYear; + } + } + + // constant that refers to the ISO standard + utils_hooks__hooks.ISO_8601 = function () {}; + + // date from string and format string + function configFromStringAndFormat(config) { + // TODO: Move this to another part of the creation flow to prevent circular deps + if (config._f === utils_hooks__hooks.ISO_8601) { + configFromISO(config); + return; + } + + config._a = []; + getParsingFlags(config).empty = true; + + // This array is used to make a Date, either with `new Date` or `Date.UTC` + var string = '' + config._i, + i, parsedInput, tokens, token, skipped, + stringLength = string.length, + totalParsedInputLength = 0; + + tokens = expandFormat(config._f, config._locale).match(formattingTokens) || []; + + for (i = 0; i < tokens.length; i++) { + token = tokens[i]; + parsedInput = (string.match(getParseRegexForToken(token, config)) || [])[0]; + // console.log('token', token, 'parsedInput', parsedInput, + // 'regex', getParseRegexForToken(token, config)); + if (parsedInput) { + skipped = string.substr(0, string.indexOf(parsedInput)); + if (skipped.length > 0) { + getParsingFlags(config).unusedInput.push(skipped); + } + string = string.slice(string.indexOf(parsedInput) + parsedInput.length); + totalParsedInputLength += parsedInput.length; + } + // don't parse if it's not a known token + if (formatTokenFunctions[token]) { + if (parsedInput) { + getParsingFlags(config).empty = false; + } + else { + getParsingFlags(config).unusedTokens.push(token); + } + addTimeToArrayFromToken(token, parsedInput, config); + } + else if (config._strict && !parsedInput) { + getParsingFlags(config).unusedTokens.push(token); + } + } + + // add remaining unparsed input length to the string + getParsingFlags(config).charsLeftOver = stringLength - totalParsedInputLength; + if (string.length > 0) { + getParsingFlags(config).unusedInput.push(string); + } + + // clear _12h flag if hour is <= 12 + if (getParsingFlags(config).bigHour === true && + config._a[HOUR] <= 12 && + config._a[HOUR] > 0) { + getParsingFlags(config).bigHour = undefined; + } + // handle meridiem + config._a[HOUR] = meridiemFixWrap(config._locale, config._a[HOUR], config._meridiem); + + configFromArray(config); + checkOverflow(config); + } + + + function meridiemFixWrap (locale, hour, meridiem) { + var isPm; + + if (meridiem == null) { + // nothing to do + return hour; + } + if (locale.meridiemHour != null) { + return locale.meridiemHour(hour, meridiem); + } else if (locale.isPM != null) { + // Fallback + isPm = locale.isPM(meridiem); + if (isPm && hour < 12) { + hour += 12; + } + if (!isPm && hour === 12) { + hour = 0; + } + return hour; + } else { + // this is not supposed to happen + return hour; + } + } + + // date from string and array of format strings + function configFromStringAndArray(config) { + var tempConfig, + bestMoment, + + scoreToBeat, + i, + currentScore; + + if (config._f.length === 0) { + getParsingFlags(config).invalidFormat = true; + config._d = new Date(NaN); + return; + } + + for (i = 0; i < config._f.length; i++) { + currentScore = 0; + tempConfig = copyConfig({}, config); + if (config._useUTC != null) { + tempConfig._useUTC = config._useUTC; + } + tempConfig._f = config._f[i]; + configFromStringAndFormat(tempConfig); + + if (!valid__isValid(tempConfig)) { + continue; + } + + // if there is any input that was not parsed add a penalty for that format + currentScore += getParsingFlags(tempConfig).charsLeftOver; + + //or tokens + currentScore += getParsingFlags(tempConfig).unusedTokens.length * 10; + + getParsingFlags(tempConfig).score = currentScore; + + if (scoreToBeat == null || currentScore < scoreToBeat) { + scoreToBeat = currentScore; + bestMoment = tempConfig; + } + } + + extend(config, bestMoment || tempConfig); + } + + function configFromObject(config) { + if (config._d) { + return; + } + + var i = normalizeObjectUnits(config._i); + config._a = map([i.year, i.month, i.day || i.date, i.hour, i.minute, i.second, i.millisecond], function (obj) { + return obj && parseInt(obj, 10); + }); + + configFromArray(config); + } + + function createFromConfig (config) { + var res = new Moment(checkOverflow(prepareConfig(config))); + if (res._nextDay) { + // Adding is smart enough around DST + res.add(1, 'd'); + res._nextDay = undefined; + } + + return res; + } + + function prepareConfig (config) { + var input = config._i, + format = config._f; + + config._locale = config._locale || locale_locales__getLocale(config._l); + + if (input === null || (format === undefined && input === '')) { + return valid__createInvalid({nullInput: true}); + } + + if (typeof input === 'string') { + config._i = input = config._locale.preparse(input); + } + + if (isMoment(input)) { + return new Moment(checkOverflow(input)); + } else if (isArray(format)) { + configFromStringAndArray(config); + } else if (format) { + configFromStringAndFormat(config); + } else if (isDate(input)) { + config._d = input; + } else { + configFromInput(config); + } + + if (!valid__isValid(config)) { + config._d = null; + } + + return config; + } + + function configFromInput(config) { + var input = config._i; + if (input === undefined) { + config._d = new Date(utils_hooks__hooks.now()); + } else if (isDate(input)) { + config._d = new Date(+input); + } else if (typeof input === 'string') { + configFromString(config); + } else if (isArray(input)) { + config._a = map(input.slice(0), function (obj) { + return parseInt(obj, 10); + }); + configFromArray(config); + } else if (typeof(input) === 'object') { + configFromObject(config); + } else if (typeof(input) === 'number') { + // from milliseconds + config._d = new Date(input); + } else { + utils_hooks__hooks.createFromInputFallback(config); + } + } + + function createLocalOrUTC (input, format, locale, strict, isUTC) { + var c = {}; + + if (typeof(locale) === 'boolean') { + strict = locale; + locale = undefined; + } + // object construction must be done this way. + // https://github.com/moment/moment/issues/1423 + c._isAMomentObject = true; + c._useUTC = c._isUTC = isUTC; + c._l = locale; + c._i = input; + c._f = format; + c._strict = strict; + + return createFromConfig(c); + } + + function local__createLocal (input, format, locale, strict) { + return createLocalOrUTC(input, format, locale, strict, false); + } + + var prototypeMin = deprecate( + 'moment().min is deprecated, use moment.min instead. https://github.com/moment/moment/issues/1548', + function () { + var other = local__createLocal.apply(null, arguments); + if (this.isValid() && other.isValid()) { + return other < this ? this : other; + } else { + return valid__createInvalid(); + } + } + ); + + var prototypeMax = deprecate( + 'moment().max is deprecated, use moment.max instead. https://github.com/moment/moment/issues/1548', + function () { + var other = local__createLocal.apply(null, arguments); + if (this.isValid() && other.isValid()) { + return other > this ? this : other; + } else { + return valid__createInvalid(); + } + } + ); + + // Pick a moment m from moments so that m[fn](other) is true for all + // other. This relies on the function fn to be transitive. + // + // moments should either be an array of moment objects or an array, whose + // first element is an array of moment objects. + function pickBy(fn, moments) { + var res, i; + if (moments.length === 1 && isArray(moments[0])) { + moments = moments[0]; + } + if (!moments.length) { + return local__createLocal(); + } + res = moments[0]; + for (i = 1; i < moments.length; ++i) { + if (!moments[i].isValid() || moments[i][fn](res)) { + res = moments[i]; + } + } + return res; + } + + // TODO: Use [].sort instead? + function min () { + var args = [].slice.call(arguments, 0); + + return pickBy('isBefore', args); + } + + function max () { + var args = [].slice.call(arguments, 0); + + return pickBy('isAfter', args); + } + + var now = function () { + return Date.now ? Date.now() : +(new Date()); + }; + + function Duration (duration) { + var normalizedInput = normalizeObjectUnits(duration), + years = normalizedInput.year || 0, + quarters = normalizedInput.quarter || 0, + months = normalizedInput.month || 0, + weeks = normalizedInput.week || 0, + days = normalizedInput.day || 0, + hours = normalizedInput.hour || 0, + minutes = normalizedInput.minute || 0, + seconds = normalizedInput.second || 0, + milliseconds = normalizedInput.millisecond || 0; + + // representation for dateAddRemove + this._milliseconds = +milliseconds + + seconds * 1e3 + // 1000 + minutes * 6e4 + // 1000 * 60 + hours * 36e5; // 1000 * 60 * 60 + // Because of dateAddRemove treats 24 hours as different from a + // day when working around DST, we need to store them separately + this._days = +days + + weeks * 7; + // It is impossible translate months into days without knowing + // which months you are are talking about, so we have to store + // it separately. + this._months = +months + + quarters * 3 + + years * 12; + + this._data = {}; + + this._locale = locale_locales__getLocale(); + + this._bubble(); + } + + function isDuration (obj) { + return obj instanceof Duration; + } + + // FORMATTING + + function offset (token, separator) { + addFormatToken(token, 0, 0, function () { + var offset = this.utcOffset(); + var sign = '+'; + if (offset < 0) { + offset = -offset; + sign = '-'; + } + return sign + zeroFill(~~(offset / 60), 2) + separator + zeroFill(~~(offset) % 60, 2); + }); + } + + offset('Z', ':'); + offset('ZZ', ''); + + // PARSING + + addRegexToken('Z', matchShortOffset); + addRegexToken('ZZ', matchShortOffset); + addParseToken(['Z', 'ZZ'], function (input, array, config) { + config._useUTC = true; + config._tzm = offsetFromString(matchShortOffset, input); + }); + + // HELPERS + + // timezone chunker + // '+10:00' > ['10', '00'] + // '-1530' > ['-15', '30'] + var chunkOffset = /([\+\-]|\d\d)/gi; + + function offsetFromString(matcher, string) { + var matches = ((string || '').match(matcher) || []); + var chunk = matches[matches.length - 1] || []; + var parts = (chunk + '').match(chunkOffset) || ['-', 0, 0]; + var minutes = +(parts[1] * 60) + toInt(parts[2]); + + return parts[0] === '+' ? minutes : -minutes; + } + + // Return a moment from input, that is local/utc/zone equivalent to model. + function cloneWithOffset(input, model) { + var res, diff; + if (model._isUTC) { + res = model.clone(); + diff = (isMoment(input) || isDate(input) ? +input : +local__createLocal(input)) - (+res); + // Use low-level api, because this fn is low-level api. + res._d.setTime(+res._d + diff); + utils_hooks__hooks.updateOffset(res, false); + return res; + } else { + return local__createLocal(input).local(); + } + } + + function getDateOffset (m) { + // On Firefox.24 Date#getTimezoneOffset returns a floating point. + // https://github.com/moment/moment/pull/1871 + return -Math.round(m._d.getTimezoneOffset() / 15) * 15; + } + + // HOOKS + + // This function will be called whenever a moment is mutated. + // It is intended to keep the offset in sync with the timezone. + utils_hooks__hooks.updateOffset = function () {}; + + // MOMENTS + + // keepLocalTime = true means only change the timezone, without + // affecting the local hour. So 5:31:26 +0300 --[utcOffset(2, true)]--> + // 5:31:26 +0200 It is possible that 5:31:26 doesn't exist with offset + // +0200, so we adjust the time as needed, to be valid. + // + // Keeping the time actually adds/subtracts (one hour) + // from the actual represented time. That is why we call updateOffset + // a second time. In case it wants us to change the offset again + // _changeInProgress == true case, then we have to adjust, because + // there is no such time in the given timezone. + function getSetOffset (input, keepLocalTime) { + var offset = this._offset || 0, + localAdjust; + if (!this.isValid()) { + return input != null ? this : NaN; + } + if (input != null) { + if (typeof input === 'string') { + input = offsetFromString(matchShortOffset, input); + } else if (Math.abs(input) < 16) { + input = input * 60; + } + if (!this._isUTC && keepLocalTime) { + localAdjust = getDateOffset(this); + } + this._offset = input; + this._isUTC = true; + if (localAdjust != null) { + this.add(localAdjust, 'm'); + } + if (offset !== input) { + if (!keepLocalTime || this._changeInProgress) { + add_subtract__addSubtract(this, create__createDuration(input - offset, 'm'), 1, false); + } else if (!this._changeInProgress) { + this._changeInProgress = true; + utils_hooks__hooks.updateOffset(this, true); + this._changeInProgress = null; + } + } + return this; + } else { + return this._isUTC ? offset : getDateOffset(this); + } + } + + function getSetZone (input, keepLocalTime) { + if (input != null) { + if (typeof input !== 'string') { + input = -input; + } + + this.utcOffset(input, keepLocalTime); + + return this; + } else { + return -this.utcOffset(); + } + } + + function setOffsetToUTC (keepLocalTime) { + return this.utcOffset(0, keepLocalTime); + } + + function setOffsetToLocal (keepLocalTime) { + if (this._isUTC) { + this.utcOffset(0, keepLocalTime); + this._isUTC = false; + + if (keepLocalTime) { + this.subtract(getDateOffset(this), 'm'); + } + } + return this; + } + + function setOffsetToParsedOffset () { + if (this._tzm) { + this.utcOffset(this._tzm); + } else if (typeof this._i === 'string') { + this.utcOffset(offsetFromString(matchOffset, this._i)); + } + return this; + } + + function hasAlignedHourOffset (input) { + if (!this.isValid()) { + return false; + } + input = input ? local__createLocal(input).utcOffset() : 0; + + return (this.utcOffset() - input) % 60 === 0; + } + + function isDaylightSavingTime () { + return ( + this.utcOffset() > this.clone().month(0).utcOffset() || + this.utcOffset() > this.clone().month(5).utcOffset() + ); + } + + function isDaylightSavingTimeShifted () { + if (!isUndefined(this._isDSTShifted)) { + return this._isDSTShifted; + } + + var c = {}; + + copyConfig(c, this); + c = prepareConfig(c); + + if (c._a) { + var other = c._isUTC ? create_utc__createUTC(c._a) : local__createLocal(c._a); + this._isDSTShifted = this.isValid() && + compareArrays(c._a, other.toArray()) > 0; + } else { + this._isDSTShifted = false; + } + + return this._isDSTShifted; + } + + function isLocal () { + return this.isValid() ? !this._isUTC : false; + } + + function isUtcOffset () { + return this.isValid() ? this._isUTC : false; + } + + function isUtc () { + return this.isValid() ? this._isUTC && this._offset === 0 : false; + } + + // ASP.NET json date format regex + var aspNetRegex = /^(\-)?(?:(\d*)[. ])?(\d+)\:(\d+)(?:\:(\d+)\.?(\d{3})?\d*)?$/; + + // from http://docs.closure-library.googlecode.com/git/closure_goog_date_date.js.source.html + // somewhat more in line with 4.4.3.2 2004 spec, but allows decimal anywhere + var isoRegex = /^(-)?P(?:(?:([0-9,.]*)Y)?(?:([0-9,.]*)M)?(?:([0-9,.]*)D)?(?:T(?:([0-9,.]*)H)?(?:([0-9,.]*)M)?(?:([0-9,.]*)S)?)?|([0-9,.]*)W)$/; + + function create__createDuration (input, key) { + var duration = input, + // matching against regexp is expensive, do it on demand + match = null, + sign, + ret, + diffRes; + + if (isDuration(input)) { + duration = { + ms : input._milliseconds, + d : input._days, + M : input._months + }; + } else if (typeof input === 'number') { + duration = {}; + if (key) { + duration[key] = input; + } else { + duration.milliseconds = input; + } + } else if (!!(match = aspNetRegex.exec(input))) { + sign = (match[1] === '-') ? -1 : 1; + duration = { + y : 0, + d : toInt(match[DATE]) * sign, + h : toInt(match[HOUR]) * sign, + m : toInt(match[MINUTE]) * sign, + s : toInt(match[SECOND]) * sign, + ms : toInt(match[MILLISECOND]) * sign + }; + } else if (!!(match = isoRegex.exec(input))) { + sign = (match[1] === '-') ? -1 : 1; + duration = { + y : parseIso(match[2], sign), + M : parseIso(match[3], sign), + d : parseIso(match[4], sign), + h : parseIso(match[5], sign), + m : parseIso(match[6], sign), + s : parseIso(match[7], sign), + w : parseIso(match[8], sign) + }; + } else if (duration == null) {// checks for null or undefined + duration = {}; + } else if (typeof duration === 'object' && ('from' in duration || 'to' in duration)) { + diffRes = momentsDifference(local__createLocal(duration.from), local__createLocal(duration.to)); + + duration = {}; + duration.ms = diffRes.milliseconds; + duration.M = diffRes.months; + } + + ret = new Duration(duration); + + if (isDuration(input) && hasOwnProp(input, '_locale')) { + ret._locale = input._locale; + } + + return ret; + } + + create__createDuration.fn = Duration.prototype; + + function parseIso (inp, sign) { + // We'd normally use ~~inp for this, but unfortunately it also + // converts floats to ints. + // inp may be undefined, so careful calling replace on it. + var res = inp && parseFloat(inp.replace(',', '.')); + // apply sign while we're at it + return (isNaN(res) ? 0 : res) * sign; + } + + function positiveMomentsDifference(base, other) { + var res = {milliseconds: 0, months: 0}; + + res.months = other.month() - base.month() + + (other.year() - base.year()) * 12; + if (base.clone().add(res.months, 'M').isAfter(other)) { + --res.months; + } + + res.milliseconds = +other - +(base.clone().add(res.months, 'M')); + + return res; + } + + function momentsDifference(base, other) { + var res; + if (!(base.isValid() && other.isValid())) { + return {milliseconds: 0, months: 0}; + } + + other = cloneWithOffset(other, base); + if (base.isBefore(other)) { + res = positiveMomentsDifference(base, other); + } else { + res = positiveMomentsDifference(other, base); + res.milliseconds = -res.milliseconds; + res.months = -res.months; + } + + return res; + } + + // TODO: remove 'name' arg after deprecation is removed + function createAdder(direction, name) { + return function (val, period) { + var dur, tmp; + //invert the arguments, but complain about it + if (period !== null && !isNaN(+period)) { + deprecateSimple(name, 'moment().' + name + '(period, number) is deprecated. Please use moment().' + name + '(number, period).'); + tmp = val; val = period; period = tmp; + } + + val = typeof val === 'string' ? +val : val; + dur = create__createDuration(val, period); + add_subtract__addSubtract(this, dur, direction); + return this; + }; + } + + function add_subtract__addSubtract (mom, duration, isAdding, updateOffset) { + var milliseconds = duration._milliseconds, + days = duration._days, + months = duration._months; + + if (!mom.isValid()) { + // No op + return; + } + + updateOffset = updateOffset == null ? true : updateOffset; + + if (milliseconds) { + mom._d.setTime(+mom._d + milliseconds * isAdding); + } + if (days) { + get_set__set(mom, 'Date', get_set__get(mom, 'Date') + days * isAdding); + } + if (months) { + setMonth(mom, get_set__get(mom, 'Month') + months * isAdding); + } + if (updateOffset) { + utils_hooks__hooks.updateOffset(mom, days || months); + } + } + + var add_subtract__add = createAdder(1, 'add'); + var add_subtract__subtract = createAdder(-1, 'subtract'); + + function moment_calendar__calendar (time, formats) { + // We want to compare the start of today, vs this. + // Getting start-of-today depends on whether we're local/utc/offset or not. + var now = time || local__createLocal(), + sod = cloneWithOffset(now, this).startOf('day'), + diff = this.diff(sod, 'days', true), + format = diff < -6 ? 'sameElse' : + diff < -1 ? 'lastWeek' : + diff < 0 ? 'lastDay' : + diff < 1 ? 'sameDay' : + diff < 2 ? 'nextDay' : + diff < 7 ? 'nextWeek' : 'sameElse'; + + var output = formats && (isFunction(formats[format]) ? formats[format]() : formats[format]); + + return this.format(output || this.localeData().calendar(format, this, local__createLocal(now))); + } + + function clone () { + return new Moment(this); + } + + function isAfter (input, units) { + var localInput = isMoment(input) ? input : local__createLocal(input); + if (!(this.isValid() && localInput.isValid())) { + return false; + } + units = normalizeUnits(!isUndefined(units) ? units : 'millisecond'); + if (units === 'millisecond') { + return +this > +localInput; + } else { + return +localInput < +this.clone().startOf(units); + } + } + + function isBefore (input, units) { + var localInput = isMoment(input) ? input : local__createLocal(input); + if (!(this.isValid() && localInput.isValid())) { + return false; + } + units = normalizeUnits(!isUndefined(units) ? units : 'millisecond'); + if (units === 'millisecond') { + return +this < +localInput; + } else { + return +this.clone().endOf(units) < +localInput; + } + } + + function isBetween (from, to, units) { + return this.isAfter(from, units) && this.isBefore(to, units); + } + + function isSame (input, units) { + var localInput = isMoment(input) ? input : local__createLocal(input), + inputMs; + if (!(this.isValid() && localInput.isValid())) { + return false; + } + units = normalizeUnits(units || 'millisecond'); + if (units === 'millisecond') { + return +this === +localInput; + } else { + inputMs = +localInput; + return +(this.clone().startOf(units)) <= inputMs && inputMs <= +(this.clone().endOf(units)); + } + } + + function isSameOrAfter (input, units) { + return this.isSame(input, units) || this.isAfter(input,units); + } + + function isSameOrBefore (input, units) { + return this.isSame(input, units) || this.isBefore(input,units); + } + + function diff (input, units, asFloat) { + var that, + zoneDelta, + delta, output; + + if (!this.isValid()) { + return NaN; + } + + that = cloneWithOffset(input, this); + + if (!that.isValid()) { + return NaN; + } + + zoneDelta = (that.utcOffset() - this.utcOffset()) * 6e4; + + units = normalizeUnits(units); + + if (units === 'year' || units === 'month' || units === 'quarter') { + output = monthDiff(this, that); + if (units === 'quarter') { + output = output / 3; + } else if (units === 'year') { + output = output / 12; + } + } else { + delta = this - that; + output = units === 'second' ? delta / 1e3 : // 1000 + units === 'minute' ? delta / 6e4 : // 1000 * 60 + units === 'hour' ? delta / 36e5 : // 1000 * 60 * 60 + units === 'day' ? (delta - zoneDelta) / 864e5 : // 1000 * 60 * 60 * 24, negate dst + units === 'week' ? (delta - zoneDelta) / 6048e5 : // 1000 * 60 * 60 * 24 * 7, negate dst + delta; + } + return asFloat ? output : absFloor(output); + } + + function monthDiff (a, b) { + // difference in months + var wholeMonthDiff = ((b.year() - a.year()) * 12) + (b.month() - a.month()), + // b is in (anchor - 1 month, anchor + 1 month) + anchor = a.clone().add(wholeMonthDiff, 'months'), + anchor2, adjust; + + if (b - anchor < 0) { + anchor2 = a.clone().add(wholeMonthDiff - 1, 'months'); + // linear across the month + adjust = (b - anchor) / (anchor - anchor2); + } else { + anchor2 = a.clone().add(wholeMonthDiff + 1, 'months'); + // linear across the month + adjust = (b - anchor) / (anchor2 - anchor); + } + + return -(wholeMonthDiff + adjust); + } + + utils_hooks__hooks.defaultFormat = 'YYYY-MM-DDTHH:mm:ssZ'; + + function toString () { + return this.clone().locale('en').format('ddd MMM DD YYYY HH:mm:ss [GMT]ZZ'); + } + + function moment_format__toISOString () { + var m = this.clone().utc(); + if (0 < m.year() && m.year() <= 9999) { + if (isFunction(Date.prototype.toISOString)) { + // native implementation is ~50x faster, use it when we can + return this.toDate().toISOString(); + } else { + return formatMoment(m, 'YYYY-MM-DD[T]HH:mm:ss.SSS[Z]'); + } + } else { + return formatMoment(m, 'YYYYYY-MM-DD[T]HH:mm:ss.SSS[Z]'); + } + } + + function format (inputString) { + var output = formatMoment(this, inputString || utils_hooks__hooks.defaultFormat); + return this.localeData().postformat(output); + } + + function from (time, withoutSuffix) { + if (this.isValid() && + ((isMoment(time) && time.isValid()) || + local__createLocal(time).isValid())) { + return create__createDuration({to: this, from: time}).locale(this.locale()).humanize(!withoutSuffix); + } else { + return this.localeData().invalidDate(); + } + } + + function fromNow (withoutSuffix) { + return this.from(local__createLocal(), withoutSuffix); + } + + function to (time, withoutSuffix) { + if (this.isValid() && + ((isMoment(time) && time.isValid()) || + local__createLocal(time).isValid())) { + return create__createDuration({from: this, to: time}).locale(this.locale()).humanize(!withoutSuffix); + } else { + return this.localeData().invalidDate(); + } + } + + function toNow (withoutSuffix) { + return this.to(local__createLocal(), withoutSuffix); + } + + // If passed a locale key, it will set the locale for this + // instance. Otherwise, it will return the locale configuration + // variables for this instance. + function locale (key) { + var newLocaleData; + + if (key === undefined) { + return this._locale._abbr; + } else { + newLocaleData = locale_locales__getLocale(key); + if (newLocaleData != null) { + this._locale = newLocaleData; + } + return this; + } + } + + var lang = deprecate( + 'moment().lang() is deprecated. Instead, use moment().localeData() to get the language configuration. Use moment().locale() to change languages.', + function (key) { + if (key === undefined) { + return this.localeData(); + } else { + return this.locale(key); + } + } + ); + + function localeData () { + return this._locale; + } + + function startOf (units) { + units = normalizeUnits(units); + // the following switch intentionally omits break keywords + // to utilize falling through the cases. + switch (units) { + case 'year': + this.month(0); + /* falls through */ + case 'quarter': + case 'month': + this.date(1); + /* falls through */ + case 'week': + case 'isoWeek': + case 'day': + this.hours(0); + /* falls through */ + case 'hour': + this.minutes(0); + /* falls through */ + case 'minute': + this.seconds(0); + /* falls through */ + case 'second': + this.milliseconds(0); + } + + // weeks are a special case + if (units === 'week') { + this.weekday(0); + } + if (units === 'isoWeek') { + this.isoWeekday(1); + } + + // quarters are also special + if (units === 'quarter') { + this.month(Math.floor(this.month() / 3) * 3); + } + + return this; + } + + function endOf (units) { + units = normalizeUnits(units); + if (units === undefined || units === 'millisecond') { + return this; + } + return this.startOf(units).add(1, (units === 'isoWeek' ? 'week' : units)).subtract(1, 'ms'); + } + + function to_type__valueOf () { + return +this._d - ((this._offset || 0) * 60000); + } + + function unix () { + return Math.floor(+this / 1000); + } + + function toDate () { + return this._offset ? new Date(+this) : this._d; + } + + function toArray () { + var m = this; + return [m.year(), m.month(), m.date(), m.hour(), m.minute(), m.second(), m.millisecond()]; + } + + function toObject () { + var m = this; + return { + years: m.year(), + months: m.month(), + date: m.date(), + hours: m.hours(), + minutes: m.minutes(), + seconds: m.seconds(), + milliseconds: m.milliseconds() + }; + } + + function toJSON () { + // JSON.stringify(new Date(NaN)) === 'null' + return this.isValid() ? this.toISOString() : 'null'; + } + + function moment_valid__isValid () { + return valid__isValid(this); + } + + function parsingFlags () { + return extend({}, getParsingFlags(this)); + } + + function invalidAt () { + return getParsingFlags(this).overflow; + } + + function creationData() { + return { + input: this._i, + format: this._f, + locale: this._locale, + isUTC: this._isUTC, + strict: this._strict + }; + } + + // FORMATTING + + addFormatToken(0, ['gg', 2], 0, function () { + return this.weekYear() % 100; + }); + + addFormatToken(0, ['GG', 2], 0, function () { + return this.isoWeekYear() % 100; + }); + + function addWeekYearFormatToken (token, getter) { + addFormatToken(0, [token, token.length], 0, getter); + } + + addWeekYearFormatToken('gggg', 'weekYear'); + addWeekYearFormatToken('ggggg', 'weekYear'); + addWeekYearFormatToken('GGGG', 'isoWeekYear'); + addWeekYearFormatToken('GGGGG', 'isoWeekYear'); + + // ALIASES + + addUnitAlias('weekYear', 'gg'); + addUnitAlias('isoWeekYear', 'GG'); + + // PARSING + + addRegexToken('G', matchSigned); + addRegexToken('g', matchSigned); + addRegexToken('GG', match1to2, match2); + addRegexToken('gg', match1to2, match2); + addRegexToken('GGGG', match1to4, match4); + addRegexToken('gggg', match1to4, match4); + addRegexToken('GGGGG', match1to6, match6); + addRegexToken('ggggg', match1to6, match6); + + addWeekParseToken(['gggg', 'ggggg', 'GGGG', 'GGGGG'], function (input, week, config, token) { + week[token.substr(0, 2)] = toInt(input); + }); + + addWeekParseToken(['gg', 'GG'], function (input, week, config, token) { + week[token] = utils_hooks__hooks.parseTwoDigitYear(input); + }); + + // MOMENTS + + function getSetWeekYear (input) { + return getSetWeekYearHelper.call(this, + input, + this.week(), + this.weekday(), + this.localeData()._week.dow, + this.localeData()._week.doy); + } + + function getSetISOWeekYear (input) { + return getSetWeekYearHelper.call(this, + input, this.isoWeek(), this.isoWeekday(), 1, 4); + } + + function getISOWeeksInYear () { + return weeksInYear(this.year(), 1, 4); + } + + function getWeeksInYear () { + var weekInfo = this.localeData()._week; + return weeksInYear(this.year(), weekInfo.dow, weekInfo.doy); + } + + function getSetWeekYearHelper(input, week, weekday, dow, doy) { + var weeksTarget; + if (input == null) { + return weekOfYear(this, dow, doy).year; + } else { + weeksTarget = weeksInYear(input, dow, doy); + if (week > weeksTarget) { + week = weeksTarget; + } + return setWeekAll.call(this, input, week, weekday, dow, doy); + } + } + + function setWeekAll(weekYear, week, weekday, dow, doy) { + var dayOfYearData = dayOfYearFromWeeks(weekYear, week, weekday, dow, doy), + date = createUTCDate(dayOfYearData.year, 0, dayOfYearData.dayOfYear); + + // console.log("got", weekYear, week, weekday, "set", date.toISOString()); + this.year(date.getUTCFullYear()); + this.month(date.getUTCMonth()); + this.date(date.getUTCDate()); + return this; + } + + // FORMATTING + + addFormatToken('Q', 0, 'Qo', 'quarter'); + + // ALIASES + + addUnitAlias('quarter', 'Q'); + + // PARSING + + addRegexToken('Q', match1); + addParseToken('Q', function (input, array) { + array[MONTH] = (toInt(input) - 1) * 3; + }); + + // MOMENTS + + function getSetQuarter (input) { + return input == null ? Math.ceil((this.month() + 1) / 3) : this.month((input - 1) * 3 + this.month() % 3); + } + + // FORMATTING + + addFormatToken('w', ['ww', 2], 'wo', 'week'); + addFormatToken('W', ['WW', 2], 'Wo', 'isoWeek'); + + // ALIASES + + addUnitAlias('week', 'w'); + addUnitAlias('isoWeek', 'W'); + + // PARSING + + addRegexToken('w', match1to2); + addRegexToken('ww', match1to2, match2); + addRegexToken('W', match1to2); + addRegexToken('WW', match1to2, match2); + + addWeekParseToken(['w', 'ww', 'W', 'WW'], function (input, week, config, token) { + week[token.substr(0, 1)] = toInt(input); + }); + + // HELPERS + + // LOCALES + + function localeWeek (mom) { + return weekOfYear(mom, this._week.dow, this._week.doy).week; + } + + var defaultLocaleWeek = { + dow : 0, // Sunday is the first day of the week. + doy : 6 // The week that contains Jan 1st is the first week of the year. + }; + + function localeFirstDayOfWeek () { + return this._week.dow; + } + + function localeFirstDayOfYear () { + return this._week.doy; + } + + // MOMENTS + + function getSetWeek (input) { + var week = this.localeData().week(this); + return input == null ? week : this.add((input - week) * 7, 'd'); + } + + function getSetISOWeek (input) { + var week = weekOfYear(this, 1, 4).week; + return input == null ? week : this.add((input - week) * 7, 'd'); + } + + // FORMATTING + + addFormatToken('D', ['DD', 2], 'Do', 'date'); + + // ALIASES + + addUnitAlias('date', 'D'); + + // PARSING + + addRegexToken('D', match1to2); + addRegexToken('DD', match1to2, match2); + addRegexToken('Do', function (isStrict, locale) { + return isStrict ? locale._ordinalParse : locale._ordinalParseLenient; + }); + + addParseToken(['D', 'DD'], DATE); + addParseToken('Do', function (input, array) { + array[DATE] = toInt(input.match(match1to2)[0], 10); + }); + + // MOMENTS + + var getSetDayOfMonth = makeGetSet('Date', true); + + // FORMATTING + + addFormatToken('d', 0, 'do', 'day'); + + addFormatToken('dd', 0, 0, function (format) { + return this.localeData().weekdaysMin(this, format); + }); + + addFormatToken('ddd', 0, 0, function (format) { + return this.localeData().weekdaysShort(this, format); + }); + + addFormatToken('dddd', 0, 0, function (format) { + return this.localeData().weekdays(this, format); + }); + + addFormatToken('e', 0, 0, 'weekday'); + addFormatToken('E', 0, 0, 'isoWeekday'); + + // ALIASES + + addUnitAlias('day', 'd'); + addUnitAlias('weekday', 'e'); + addUnitAlias('isoWeekday', 'E'); + + // PARSING + + addRegexToken('d', match1to2); + addRegexToken('e', match1to2); + addRegexToken('E', match1to2); + addRegexToken('dd', matchWord); + addRegexToken('ddd', matchWord); + addRegexToken('dddd', matchWord); + + addWeekParseToken(['dd', 'ddd', 'dddd'], function (input, week, config, token) { + var weekday = config._locale.weekdaysParse(input, token, config._strict); + // if we didn't get a weekday name, mark the date as invalid + if (weekday != null) { + week.d = weekday; + } else { + getParsingFlags(config).invalidWeekday = input; + } + }); + + addWeekParseToken(['d', 'e', 'E'], function (input, week, config, token) { + week[token] = toInt(input); + }); + + // HELPERS + + function parseWeekday(input, locale) { + if (typeof input !== 'string') { + return input; + } + + if (!isNaN(input)) { + return parseInt(input, 10); + } + + input = locale.weekdaysParse(input); + if (typeof input === 'number') { + return input; + } + + return null; + } + + // LOCALES + + var defaultLocaleWeekdays = 'Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday'.split('_'); + function localeWeekdays (m, format) { + return isArray(this._weekdays) ? this._weekdays[m.day()] : + this._weekdays[this._weekdays.isFormat.test(format) ? 'format' : 'standalone'][m.day()]; + } + + var defaultLocaleWeekdaysShort = 'Sun_Mon_Tue_Wed_Thu_Fri_Sat'.split('_'); + function localeWeekdaysShort (m) { + return this._weekdaysShort[m.day()]; + } + + var defaultLocaleWeekdaysMin = 'Su_Mo_Tu_We_Th_Fr_Sa'.split('_'); + function localeWeekdaysMin (m) { + return this._weekdaysMin[m.day()]; + } + + function localeWeekdaysParse (weekdayName, format, strict) { + var i, mom, regex; + + if (!this._weekdaysParse) { + this._weekdaysParse = []; + this._minWeekdaysParse = []; + this._shortWeekdaysParse = []; + this._fullWeekdaysParse = []; + } + + for (i = 0; i < 7; i++) { + // make the regex if we don't have it already + + mom = local__createLocal([2000, 1]).day(i); + if (strict && !this._fullWeekdaysParse[i]) { + this._fullWeekdaysParse[i] = new RegExp('^' + this.weekdays(mom, '').replace('.', '\.?') + '$', 'i'); + this._shortWeekdaysParse[i] = new RegExp('^' + this.weekdaysShort(mom, '').replace('.', '\.?') + '$', 'i'); + this._minWeekdaysParse[i] = new RegExp('^' + this.weekdaysMin(mom, '').replace('.', '\.?') + '$', 'i'); + } + if (!this._weekdaysParse[i]) { + regex = '^' + this.weekdays(mom, '') + '|^' + this.weekdaysShort(mom, '') + '|^' + this.weekdaysMin(mom, ''); + this._weekdaysParse[i] = new RegExp(regex.replace('.', ''), 'i'); + } + // test the regex + if (strict && format === 'dddd' && this._fullWeekdaysParse[i].test(weekdayName)) { + return i; + } else if (strict && format === 'ddd' && this._shortWeekdaysParse[i].test(weekdayName)) { + return i; + } else if (strict && format === 'dd' && this._minWeekdaysParse[i].test(weekdayName)) { + return i; + } else if (!strict && this._weekdaysParse[i].test(weekdayName)) { + return i; + } + } + } + + // MOMENTS + + function getSetDayOfWeek (input) { + if (!this.isValid()) { + return input != null ? this : NaN; + } + var day = this._isUTC ? this._d.getUTCDay() : this._d.getDay(); + if (input != null) { + input = parseWeekday(input, this.localeData()); + return this.add(input - day, 'd'); + } else { + return day; + } + } + + function getSetLocaleDayOfWeek (input) { + if (!this.isValid()) { + return input != null ? this : NaN; + } + var weekday = (this.day() + 7 - this.localeData()._week.dow) % 7; + return input == null ? weekday : this.add(input - weekday, 'd'); + } + + function getSetISODayOfWeek (input) { + if (!this.isValid()) { + return input != null ? this : NaN; + } + // behaves the same as moment#day except + // as a getter, returns 7 instead of 0 (1-7 range instead of 0-6) + // as a setter, sunday should belong to the previous week. + return input == null ? this.day() || 7 : this.day(this.day() % 7 ? input : input - 7); + } + + // FORMATTING + + addFormatToken('DDD', ['DDDD', 3], 'DDDo', 'dayOfYear'); + + // ALIASES + + addUnitAlias('dayOfYear', 'DDD'); + + // PARSING + + addRegexToken('DDD', match1to3); + addRegexToken('DDDD', match3); + addParseToken(['DDD', 'DDDD'], function (input, array, config) { + config._dayOfYear = toInt(input); + }); + + // HELPERS + + // MOMENTS + + function getSetDayOfYear (input) { + var dayOfYear = Math.round((this.clone().startOf('day') - this.clone().startOf('year')) / 864e5) + 1; + return input == null ? dayOfYear : this.add((input - dayOfYear), 'd'); + } + + // FORMATTING + + function hFormat() { + return this.hours() % 12 || 12; + } + + addFormatToken('H', ['HH', 2], 0, 'hour'); + addFormatToken('h', ['hh', 2], 0, hFormat); + + addFormatToken('hmm', 0, 0, function () { + return '' + hFormat.apply(this) + zeroFill(this.minutes(), 2); + }); + + addFormatToken('hmmss', 0, 0, function () { + return '' + hFormat.apply(this) + zeroFill(this.minutes(), 2) + + zeroFill(this.seconds(), 2); + }); + + addFormatToken('Hmm', 0, 0, function () { + return '' + this.hours() + zeroFill(this.minutes(), 2); + }); + + addFormatToken('Hmmss', 0, 0, function () { + return '' + this.hours() + zeroFill(this.minutes(), 2) + + zeroFill(this.seconds(), 2); + }); + + function meridiem (token, lowercase) { + addFormatToken(token, 0, 0, function () { + return this.localeData().meridiem(this.hours(), this.minutes(), lowercase); + }); + } + + meridiem('a', true); + meridiem('A', false); + + // ALIASES + + addUnitAlias('hour', 'h'); + + // PARSING + + function matchMeridiem (isStrict, locale) { + return locale._meridiemParse; + } + + addRegexToken('a', matchMeridiem); + addRegexToken('A', matchMeridiem); + addRegexToken('H', match1to2); + addRegexToken('h', match1to2); + addRegexToken('HH', match1to2, match2); + addRegexToken('hh', match1to2, match2); + + addRegexToken('hmm', match3to4); + addRegexToken('hmmss', match5to6); + addRegexToken('Hmm', match3to4); + addRegexToken('Hmmss', match5to6); + + addParseToken(['H', 'HH'], HOUR); + addParseToken(['a', 'A'], function (input, array, config) { + config._isPm = config._locale.isPM(input); + config._meridiem = input; + }); + addParseToken(['h', 'hh'], function (input, array, config) { + array[HOUR] = toInt(input); + getParsingFlags(config).bigHour = true; + }); + addParseToken('hmm', function (input, array, config) { + var pos = input.length - 2; + array[HOUR] = toInt(input.substr(0, pos)); + array[MINUTE] = toInt(input.substr(pos)); + getParsingFlags(config).bigHour = true; + }); + addParseToken('hmmss', function (input, array, config) { + var pos1 = input.length - 4; + var pos2 = input.length - 2; + array[HOUR] = toInt(input.substr(0, pos1)); + array[MINUTE] = toInt(input.substr(pos1, 2)); + array[SECOND] = toInt(input.substr(pos2)); + getParsingFlags(config).bigHour = true; + }); + addParseToken('Hmm', function (input, array, config) { + var pos = input.length - 2; + array[HOUR] = toInt(input.substr(0, pos)); + array[MINUTE] = toInt(input.substr(pos)); + }); + addParseToken('Hmmss', function (input, array, config) { + var pos1 = input.length - 4; + var pos2 = input.length - 2; + array[HOUR] = toInt(input.substr(0, pos1)); + array[MINUTE] = toInt(input.substr(pos1, 2)); + array[SECOND] = toInt(input.substr(pos2)); + }); + + // LOCALES + + function localeIsPM (input) { + // IE8 Quirks Mode & IE7 Standards Mode do not allow accessing strings like arrays + // Using charAt should be more compatible. + return ((input + '').toLowerCase().charAt(0) === 'p'); + } + + var defaultLocaleMeridiemParse = /[ap]\.?m?\.?/i; + function localeMeridiem (hours, minutes, isLower) { + if (hours > 11) { + return isLower ? 'pm' : 'PM'; + } else { + return isLower ? 'am' : 'AM'; + } + } + + + // MOMENTS + + // Setting the hour should keep the time, because the user explicitly + // specified which hour he wants. So trying to maintain the same hour (in + // a new timezone) makes sense. Adding/subtracting hours does not follow + // this rule. + var getSetHour = makeGetSet('Hours', true); + + // FORMATTING + + addFormatToken('m', ['mm', 2], 0, 'minute'); + + // ALIASES + + addUnitAlias('minute', 'm'); + + // PARSING + + addRegexToken('m', match1to2); + addRegexToken('mm', match1to2, match2); + addParseToken(['m', 'mm'], MINUTE); + + // MOMENTS + + var getSetMinute = makeGetSet('Minutes', false); + + // FORMATTING + + addFormatToken('s', ['ss', 2], 0, 'second'); + + // ALIASES + + addUnitAlias('second', 's'); + + // PARSING + + addRegexToken('s', match1to2); + addRegexToken('ss', match1to2, match2); + addParseToken(['s', 'ss'], SECOND); + + // MOMENTS + + var getSetSecond = makeGetSet('Seconds', false); + + // FORMATTING + + addFormatToken('S', 0, 0, function () { + return ~~(this.millisecond() / 100); + }); + + addFormatToken(0, ['SS', 2], 0, function () { + return ~~(this.millisecond() / 10); + }); + + addFormatToken(0, ['SSS', 3], 0, 'millisecond'); + addFormatToken(0, ['SSSS', 4], 0, function () { + return this.millisecond() * 10; + }); + addFormatToken(0, ['SSSSS', 5], 0, function () { + return this.millisecond() * 100; + }); + addFormatToken(0, ['SSSSSS', 6], 0, function () { + return this.millisecond() * 1000; + }); + addFormatToken(0, ['SSSSSSS', 7], 0, function () { + return this.millisecond() * 10000; + }); + addFormatToken(0, ['SSSSSSSS', 8], 0, function () { + return this.millisecond() * 100000; + }); + addFormatToken(0, ['SSSSSSSSS', 9], 0, function () { + return this.millisecond() * 1000000; + }); + + + // ALIASES + + addUnitAlias('millisecond', 'ms'); + + // PARSING + + addRegexToken('S', match1to3, match1); + addRegexToken('SS', match1to3, match2); + addRegexToken('SSS', match1to3, match3); + + var token; + for (token = 'SSSS'; token.length <= 9; token += 'S') { + addRegexToken(token, matchUnsigned); + } + + function parseMs(input, array) { + array[MILLISECOND] = toInt(('0.' + input) * 1000); + } + + for (token = 'S'; token.length <= 9; token += 'S') { + addParseToken(token, parseMs); + } + // MOMENTS + + var getSetMillisecond = makeGetSet('Milliseconds', false); + + // FORMATTING + + addFormatToken('z', 0, 0, 'zoneAbbr'); + addFormatToken('zz', 0, 0, 'zoneName'); + + // MOMENTS + + function getZoneAbbr () { + return this._isUTC ? 'UTC' : ''; + } + + function getZoneName () { + return this._isUTC ? 'Coordinated Universal Time' : ''; + } + + var momentPrototype__proto = Moment.prototype; + + momentPrototype__proto.add = add_subtract__add; + momentPrototype__proto.calendar = moment_calendar__calendar; + momentPrototype__proto.clone = clone; + momentPrototype__proto.diff = diff; + momentPrototype__proto.endOf = endOf; + momentPrototype__proto.format = format; + momentPrototype__proto.from = from; + momentPrototype__proto.fromNow = fromNow; + momentPrototype__proto.to = to; + momentPrototype__proto.toNow = toNow; + momentPrototype__proto.get = getSet; + momentPrototype__proto.invalidAt = invalidAt; + momentPrototype__proto.isAfter = isAfter; + momentPrototype__proto.isBefore = isBefore; + momentPrototype__proto.isBetween = isBetween; + momentPrototype__proto.isSame = isSame; + momentPrototype__proto.isSameOrAfter = isSameOrAfter; + momentPrototype__proto.isSameOrBefore = isSameOrBefore; + momentPrototype__proto.isValid = moment_valid__isValid; + momentPrototype__proto.lang = lang; + momentPrototype__proto.locale = locale; + momentPrototype__proto.localeData = localeData; + momentPrototype__proto.max = prototypeMax; + momentPrototype__proto.min = prototypeMin; + momentPrototype__proto.parsingFlags = parsingFlags; + momentPrototype__proto.set = getSet; + momentPrototype__proto.startOf = startOf; + momentPrototype__proto.subtract = add_subtract__subtract; + momentPrototype__proto.toArray = toArray; + momentPrototype__proto.toObject = toObject; + momentPrototype__proto.toDate = toDate; + momentPrototype__proto.toISOString = moment_format__toISOString; + momentPrototype__proto.toJSON = toJSON; + momentPrototype__proto.toString = toString; + momentPrototype__proto.unix = unix; + momentPrototype__proto.valueOf = to_type__valueOf; + momentPrototype__proto.creationData = creationData; + + // Year + momentPrototype__proto.year = getSetYear; + momentPrototype__proto.isLeapYear = getIsLeapYear; + + // Week Year + momentPrototype__proto.weekYear = getSetWeekYear; + momentPrototype__proto.isoWeekYear = getSetISOWeekYear; + + // Quarter + momentPrototype__proto.quarter = momentPrototype__proto.quarters = getSetQuarter; + + // Month + momentPrototype__proto.month = getSetMonth; + momentPrototype__proto.daysInMonth = getDaysInMonth; + + // Week + momentPrototype__proto.week = momentPrototype__proto.weeks = getSetWeek; + momentPrototype__proto.isoWeek = momentPrototype__proto.isoWeeks = getSetISOWeek; + momentPrototype__proto.weeksInYear = getWeeksInYear; + momentPrototype__proto.isoWeeksInYear = getISOWeeksInYear; + + // Day + momentPrototype__proto.date = getSetDayOfMonth; + momentPrototype__proto.day = momentPrototype__proto.days = getSetDayOfWeek; + momentPrototype__proto.weekday = getSetLocaleDayOfWeek; + momentPrototype__proto.isoWeekday = getSetISODayOfWeek; + momentPrototype__proto.dayOfYear = getSetDayOfYear; + + // Hour + momentPrototype__proto.hour = momentPrototype__proto.hours = getSetHour; + + // Minute + momentPrototype__proto.minute = momentPrototype__proto.minutes = getSetMinute; + + // Second + momentPrototype__proto.second = momentPrototype__proto.seconds = getSetSecond; + + // Millisecond + momentPrototype__proto.millisecond = momentPrototype__proto.milliseconds = getSetMillisecond; + + // Offset + momentPrototype__proto.utcOffset = getSetOffset; + momentPrototype__proto.utc = setOffsetToUTC; + momentPrototype__proto.local = setOffsetToLocal; + momentPrototype__proto.parseZone = setOffsetToParsedOffset; + momentPrototype__proto.hasAlignedHourOffset = hasAlignedHourOffset; + momentPrototype__proto.isDST = isDaylightSavingTime; + momentPrototype__proto.isDSTShifted = isDaylightSavingTimeShifted; + momentPrototype__proto.isLocal = isLocal; + momentPrototype__proto.isUtcOffset = isUtcOffset; + momentPrototype__proto.isUtc = isUtc; + momentPrototype__proto.isUTC = isUtc; + + // Timezone + momentPrototype__proto.zoneAbbr = getZoneAbbr; + momentPrototype__proto.zoneName = getZoneName; + + // Deprecations + momentPrototype__proto.dates = deprecate('dates accessor is deprecated. Use date instead.', getSetDayOfMonth); + momentPrototype__proto.months = deprecate('months accessor is deprecated. Use month instead', getSetMonth); + momentPrototype__proto.years = deprecate('years accessor is deprecated. Use year instead', getSetYear); + momentPrototype__proto.zone = deprecate('moment().zone is deprecated, use moment().utcOffset instead. https://github.com/moment/moment/issues/1779', getSetZone); + + var momentPrototype = momentPrototype__proto; + + function moment__createUnix (input) { + return local__createLocal(input * 1000); + } + + function moment__createInZone () { + return local__createLocal.apply(null, arguments).parseZone(); + } + + var defaultCalendar = { + sameDay : '[Today at] LT', + nextDay : '[Tomorrow at] LT', + nextWeek : 'dddd [at] LT', + lastDay : '[Yesterday at] LT', + lastWeek : '[Last] dddd [at] LT', + sameElse : 'L' + }; + + function locale_calendar__calendar (key, mom, now) { + var output = this._calendar[key]; + return isFunction(output) ? output.call(mom, now) : output; + } + + var defaultLongDateFormat = { + LTS : 'h:mm:ss A', + LT : 'h:mm A', + L : 'MM/DD/YYYY', + LL : 'MMMM D, YYYY', + LLL : 'MMMM D, YYYY h:mm A', + LLLL : 'dddd, MMMM D, YYYY h:mm A' + }; + + function longDateFormat (key) { + var format = this._longDateFormat[key], + formatUpper = this._longDateFormat[key.toUpperCase()]; + + if (format || !formatUpper) { + return format; + } + + this._longDateFormat[key] = formatUpper.replace(/MMMM|MM|DD|dddd/g, function (val) { + return val.slice(1); + }); + + return this._longDateFormat[key]; + } + + var defaultInvalidDate = 'Invalid date'; + + function invalidDate () { + return this._invalidDate; + } + + var defaultOrdinal = '%d'; + var defaultOrdinalParse = /\d{1,2}/; + + function ordinal (number) { + return this._ordinal.replace('%d', number); + } + + function preParsePostFormat (string) { + return string; + } + + var defaultRelativeTime = { + future : 'in %s', + past : '%s ago', + s : 'a few seconds', + m : 'a minute', + mm : '%d minutes', + h : 'an hour', + hh : '%d hours', + d : 'a day', + dd : '%d days', + M : 'a month', + MM : '%d months', + y : 'a year', + yy : '%d years' + }; + + function relative__relativeTime (number, withoutSuffix, string, isFuture) { + var output = this._relativeTime[string]; + return (isFunction(output)) ? + output(number, withoutSuffix, string, isFuture) : + output.replace(/%d/i, number); + } + + function pastFuture (diff, output) { + var format = this._relativeTime[diff > 0 ? 'future' : 'past']; + return isFunction(format) ? format(output) : format.replace(/%s/i, output); + } + + function locale_set__set (config) { + var prop, i; + for (i in config) { + prop = config[i]; + if (isFunction(prop)) { + this[i] = prop; + } else { + this['_' + i] = prop; + } + } + // Lenient ordinal parsing accepts just a number in addition to + // number + (possibly) stuff coming from _ordinalParseLenient. + this._ordinalParseLenient = new RegExp(this._ordinalParse.source + '|' + (/\d{1,2}/).source); + } + + var prototype__proto = Locale.prototype; + + prototype__proto._calendar = defaultCalendar; + prototype__proto.calendar = locale_calendar__calendar; + prototype__proto._longDateFormat = defaultLongDateFormat; + prototype__proto.longDateFormat = longDateFormat; + prototype__proto._invalidDate = defaultInvalidDate; + prototype__proto.invalidDate = invalidDate; + prototype__proto._ordinal = defaultOrdinal; + prototype__proto.ordinal = ordinal; + prototype__proto._ordinalParse = defaultOrdinalParse; + prototype__proto.preparse = preParsePostFormat; + prototype__proto.postformat = preParsePostFormat; + prototype__proto._relativeTime = defaultRelativeTime; + prototype__proto.relativeTime = relative__relativeTime; + prototype__proto.pastFuture = pastFuture; + prototype__proto.set = locale_set__set; + + // Month + prototype__proto.months = localeMonths; + prototype__proto._months = defaultLocaleMonths; + prototype__proto.monthsShort = localeMonthsShort; + prototype__proto._monthsShort = defaultLocaleMonthsShort; + prototype__proto.monthsParse = localeMonthsParse; + prototype__proto._monthsRegex = defaultMonthsRegex; + prototype__proto.monthsRegex = monthsRegex; + prototype__proto._monthsShortRegex = defaultMonthsShortRegex; + prototype__proto.monthsShortRegex = monthsShortRegex; + + // Week + prototype__proto.week = localeWeek; + prototype__proto._week = defaultLocaleWeek; + prototype__proto.firstDayOfYear = localeFirstDayOfYear; + prototype__proto.firstDayOfWeek = localeFirstDayOfWeek; + + // Day of Week + prototype__proto.weekdays = localeWeekdays; + prototype__proto._weekdays = defaultLocaleWeekdays; + prototype__proto.weekdaysMin = localeWeekdaysMin; + prototype__proto._weekdaysMin = defaultLocaleWeekdaysMin; + prototype__proto.weekdaysShort = localeWeekdaysShort; + prototype__proto._weekdaysShort = defaultLocaleWeekdaysShort; + prototype__proto.weekdaysParse = localeWeekdaysParse; + + // Hours + prototype__proto.isPM = localeIsPM; + prototype__proto._meridiemParse = defaultLocaleMeridiemParse; + prototype__proto.meridiem = localeMeridiem; + + function lists__get (format, index, field, setter) { + var locale = locale_locales__getLocale(); + var utc = create_utc__createUTC().set(setter, index); + return locale[field](utc, format); + } + + function list (format, index, field, count, setter) { + if (typeof format === 'number') { + index = format; + format = undefined; + } + + format = format || ''; + + if (index != null) { + return lists__get(format, index, field, setter); + } + + var i; + var out = []; + for (i = 0; i < count; i++) { + out[i] = lists__get(format, i, field, setter); + } + return out; + } + + function lists__listMonths (format, index) { + return list(format, index, 'months', 12, 'month'); + } + + function lists__listMonthsShort (format, index) { + return list(format, index, 'monthsShort', 12, 'month'); + } + + function lists__listWeekdays (format, index) { + return list(format, index, 'weekdays', 7, 'day'); + } + + function lists__listWeekdaysShort (format, index) { + return list(format, index, 'weekdaysShort', 7, 'day'); + } + + function lists__listWeekdaysMin (format, index) { + return list(format, index, 'weekdaysMin', 7, 'day'); + } + + locale_locales__getSetGlobalLocale('en', { + ordinalParse: /\d{1,2}(th|st|nd|rd)/, + ordinal : function (number) { + var b = number % 10, + output = (toInt(number % 100 / 10) === 1) ? 'th' : + (b === 1) ? 'st' : + (b === 2) ? 'nd' : + (b === 3) ? 'rd' : 'th'; + return number + output; + } + }); + + // Side effect imports + utils_hooks__hooks.lang = deprecate('moment.lang is deprecated. Use moment.locale instead.', locale_locales__getSetGlobalLocale); + utils_hooks__hooks.langData = deprecate('moment.langData is deprecated. Use moment.localeData instead.', locale_locales__getLocale); + + var mathAbs = Math.abs; + + function duration_abs__abs () { + var data = this._data; + + this._milliseconds = mathAbs(this._milliseconds); + this._days = mathAbs(this._days); + this._months = mathAbs(this._months); + + data.milliseconds = mathAbs(data.milliseconds); + data.seconds = mathAbs(data.seconds); + data.minutes = mathAbs(data.minutes); + data.hours = mathAbs(data.hours); + data.months = mathAbs(data.months); + data.years = mathAbs(data.years); + + return this; + } + + function duration_add_subtract__addSubtract (duration, input, value, direction) { + var other = create__createDuration(input, value); + + duration._milliseconds += direction * other._milliseconds; + duration._days += direction * other._days; + duration._months += direction * other._months; + + return duration._bubble(); + } + + // supports only 2.0-style add(1, 's') or add(duration) + function duration_add_subtract__add (input, value) { + return duration_add_subtract__addSubtract(this, input, value, 1); + } + + // supports only 2.0-style subtract(1, 's') or subtract(duration) + function duration_add_subtract__subtract (input, value) { + return duration_add_subtract__addSubtract(this, input, value, -1); + } + + function absCeil (number) { + if (number < 0) { + return Math.floor(number); + } else { + return Math.ceil(number); + } + } + + function bubble () { + var milliseconds = this._milliseconds; + var days = this._days; + var months = this._months; + var data = this._data; + var seconds, minutes, hours, years, monthsFromDays; + + // if we have a mix of positive and negative values, bubble down first + // check: https://github.com/moment/moment/issues/2166 + if (!((milliseconds >= 0 && days >= 0 && months >= 0) || + (milliseconds <= 0 && days <= 0 && months <= 0))) { + milliseconds += absCeil(monthsToDays(months) + days) * 864e5; + days = 0; + months = 0; + } + + // The following code bubbles up values, see the tests for + // examples of what that means. + data.milliseconds = milliseconds % 1000; + + seconds = absFloor(milliseconds / 1000); + data.seconds = seconds % 60; + + minutes = absFloor(seconds / 60); + data.minutes = minutes % 60; + + hours = absFloor(minutes / 60); + data.hours = hours % 24; + + days += absFloor(hours / 24); + + // convert days to months + monthsFromDays = absFloor(daysToMonths(days)); + months += monthsFromDays; + days -= absCeil(monthsToDays(monthsFromDays)); + + // 12 months -> 1 year + years = absFloor(months / 12); + months %= 12; + + data.days = days; + data.months = months; + data.years = years; + + return this; + } + + function daysToMonths (days) { + // 400 years have 146097 days (taking into account leap year rules) + // 400 years have 12 months === 4800 + return days * 4800 / 146097; + } + + function monthsToDays (months) { + // the reverse of daysToMonths + return months * 146097 / 4800; + } + + function as (units) { + var days; + var months; + var milliseconds = this._milliseconds; + + units = normalizeUnits(units); + + if (units === 'month' || units === 'year') { + days = this._days + milliseconds / 864e5; + months = this._months + daysToMonths(days); + return units === 'month' ? months : months / 12; + } else { + // handle milliseconds separately because of floating point math errors (issue #1867) + days = this._days + Math.round(monthsToDays(this._months)); + switch (units) { + case 'week' : return days / 7 + milliseconds / 6048e5; + case 'day' : return days + milliseconds / 864e5; + case 'hour' : return days * 24 + milliseconds / 36e5; + case 'minute' : return days * 1440 + milliseconds / 6e4; + case 'second' : return days * 86400 + milliseconds / 1000; + // Math.floor prevents floating point math errors here + case 'millisecond': return Math.floor(days * 864e5) + milliseconds; + default: throw new Error('Unknown unit ' + units); + } + } + } + + // TODO: Use this.as('ms')? + function duration_as__valueOf () { + return ( + this._milliseconds + + this._days * 864e5 + + (this._months % 12) * 2592e6 + + toInt(this._months / 12) * 31536e6 + ); + } + + function makeAs (alias) { + return function () { + return this.as(alias); + }; + } + + var asMilliseconds = makeAs('ms'); + var asSeconds = makeAs('s'); + var asMinutes = makeAs('m'); + var asHours = makeAs('h'); + var asDays = makeAs('d'); + var asWeeks = makeAs('w'); + var asMonths = makeAs('M'); + var asYears = makeAs('y'); + + function duration_get__get (units) { + units = normalizeUnits(units); + return this[units + 's'](); + } + + function makeGetter(name) { + return function () { + return this._data[name]; + }; + } + + var milliseconds = makeGetter('milliseconds'); + var seconds = makeGetter('seconds'); + var minutes = makeGetter('minutes'); + var hours = makeGetter('hours'); + var days = makeGetter('days'); + var months = makeGetter('months'); + var years = makeGetter('years'); + + function weeks () { + return absFloor(this.days() / 7); + } + + var round = Math.round; + var thresholds = { + s: 45, // seconds to minute + m: 45, // minutes to hour + h: 22, // hours to day + d: 26, // days to month + M: 11 // months to year + }; + + // helper function for moment.fn.from, moment.fn.fromNow, and moment.duration.fn.humanize + function substituteTimeAgo(string, number, withoutSuffix, isFuture, locale) { + return locale.relativeTime(number || 1, !!withoutSuffix, string, isFuture); + } + + function duration_humanize__relativeTime (posNegDuration, withoutSuffix, locale) { + var duration = create__createDuration(posNegDuration).abs(); + var seconds = round(duration.as('s')); + var minutes = round(duration.as('m')); + var hours = round(duration.as('h')); + var days = round(duration.as('d')); + var months = round(duration.as('M')); + var years = round(duration.as('y')); + + var a = seconds < thresholds.s && ['s', seconds] || + minutes <= 1 && ['m'] || + minutes < thresholds.m && ['mm', minutes] || + hours <= 1 && ['h'] || + hours < thresholds.h && ['hh', hours] || + days <= 1 && ['d'] || + days < thresholds.d && ['dd', days] || + months <= 1 && ['M'] || + months < thresholds.M && ['MM', months] || + years <= 1 && ['y'] || ['yy', years]; + + a[2] = withoutSuffix; + a[3] = +posNegDuration > 0; + a[4] = locale; + return substituteTimeAgo.apply(null, a); + } + + // This function allows you to set a threshold for relative time strings + function duration_humanize__getSetRelativeTimeThreshold (threshold, limit) { + if (thresholds[threshold] === undefined) { + return false; + } + if (limit === undefined) { + return thresholds[threshold]; + } + thresholds[threshold] = limit; + return true; + } + + function humanize (withSuffix) { + var locale = this.localeData(); + var output = duration_humanize__relativeTime(this, !withSuffix, locale); + + if (withSuffix) { + output = locale.pastFuture(+this, output); + } + + return locale.postformat(output); + } + + var iso_string__abs = Math.abs; + + function iso_string__toISOString() { + // for ISO strings we do not use the normal bubbling rules: + // * milliseconds bubble up until they become hours + // * days do not bubble at all + // * months bubble up until they become years + // This is because there is no context-free conversion between hours and days + // (think of clock changes) + // and also not between days and months (28-31 days per month) + var seconds = iso_string__abs(this._milliseconds) / 1000; + var days = iso_string__abs(this._days); + var months = iso_string__abs(this._months); + var minutes, hours, years; + + // 3600 seconds -> 60 minutes -> 1 hour + minutes = absFloor(seconds / 60); + hours = absFloor(minutes / 60); + seconds %= 60; + minutes %= 60; + + // 12 months -> 1 year + years = absFloor(months / 12); + months %= 12; + + + // inspired by https://github.com/dordille/moment-isoduration/blob/master/moment.isoduration.js + var Y = years; + var M = months; + var D = days; + var h = hours; + var m = minutes; + var s = seconds; + var total = this.asSeconds(); + + if (!total) { + // this is the same as C#'s (Noda) and python (isodate)... + // but not other JS (goog.date) + return 'P0D'; + } + + return (total < 0 ? '-' : '') + + 'P' + + (Y ? Y + 'Y' : '') + + (M ? M + 'M' : '') + + (D ? D + 'D' : '') + + ((h || m || s) ? 'T' : '') + + (h ? h + 'H' : '') + + (m ? m + 'M' : '') + + (s ? s + 'S' : ''); + } + + var duration_prototype__proto = Duration.prototype; + + duration_prototype__proto.abs = duration_abs__abs; + duration_prototype__proto.add = duration_add_subtract__add; + duration_prototype__proto.subtract = duration_add_subtract__subtract; + duration_prototype__proto.as = as; + duration_prototype__proto.asMilliseconds = asMilliseconds; + duration_prototype__proto.asSeconds = asSeconds; + duration_prototype__proto.asMinutes = asMinutes; + duration_prototype__proto.asHours = asHours; + duration_prototype__proto.asDays = asDays; + duration_prototype__proto.asWeeks = asWeeks; + duration_prototype__proto.asMonths = asMonths; + duration_prototype__proto.asYears = asYears; + duration_prototype__proto.valueOf = duration_as__valueOf; + duration_prototype__proto._bubble = bubble; + duration_prototype__proto.get = duration_get__get; + duration_prototype__proto.milliseconds = milliseconds; + duration_prototype__proto.seconds = seconds; + duration_prototype__proto.minutes = minutes; + duration_prototype__proto.hours = hours; + duration_prototype__proto.days = days; + duration_prototype__proto.weeks = weeks; + duration_prototype__proto.months = months; + duration_prototype__proto.years = years; + duration_prototype__proto.humanize = humanize; + duration_prototype__proto.toISOString = iso_string__toISOString; + duration_prototype__proto.toString = iso_string__toISOString; + duration_prototype__proto.toJSON = iso_string__toISOString; + duration_prototype__proto.locale = locale; + duration_prototype__proto.localeData = localeData; + + // Deprecations + duration_prototype__proto.toIsoString = deprecate('toIsoString() is deprecated. Please use toISOString() instead (notice the capitals)', iso_string__toISOString); + duration_prototype__proto.lang = lang; + + // Side effect imports + + // FORMATTING + + addFormatToken('X', 0, 0, 'unix'); + addFormatToken('x', 0, 0, 'valueOf'); + + // PARSING + + addRegexToken('x', matchSigned); + addRegexToken('X', matchTimestamp); + addParseToken('X', function (input, array, config) { + config._d = new Date(parseFloat(input, 10) * 1000); + }); + addParseToken('x', function (input, array, config) { + config._d = new Date(toInt(input)); + }); + + // Side effect imports + + + utils_hooks__hooks.version = '2.11.2'; + + setHookCallback(local__createLocal); + + utils_hooks__hooks.fn = momentPrototype; + utils_hooks__hooks.min = min; + utils_hooks__hooks.max = max; + utils_hooks__hooks.now = now; + utils_hooks__hooks.utc = create_utc__createUTC; + utils_hooks__hooks.unix = moment__createUnix; + utils_hooks__hooks.months = lists__listMonths; + utils_hooks__hooks.isDate = isDate; + utils_hooks__hooks.locale = locale_locales__getSetGlobalLocale; + utils_hooks__hooks.invalid = valid__createInvalid; + utils_hooks__hooks.duration = create__createDuration; + utils_hooks__hooks.isMoment = isMoment; + utils_hooks__hooks.weekdays = lists__listWeekdays; + utils_hooks__hooks.parseZone = moment__createInZone; + utils_hooks__hooks.localeData = locale_locales__getLocale; + utils_hooks__hooks.isDuration = isDuration; + utils_hooks__hooks.monthsShort = lists__listMonthsShort; + utils_hooks__hooks.weekdaysMin = lists__listWeekdaysMin; + utils_hooks__hooks.defineLocale = defineLocale; + utils_hooks__hooks.weekdaysShort = lists__listWeekdaysShort; + utils_hooks__hooks.normalizeUnits = normalizeUnits; + utils_hooks__hooks.relativeTimeThreshold = duration_humanize__getSetRelativeTimeThreshold; + utils_hooks__hooks.prototype = momentPrototype; + + var _moment = utils_hooks__hooks; + + return _moment; + +})); \ No newline at end of file diff --git a/html/allsky/ng-lodash.min.js b/html/allsky/ng-lodash.min.js new file mode 100755 index 000000000..d48937d05 --- /dev/null +++ b/html/allsky/ng-lodash.min.js @@ -0,0 +1,11 @@ +/** + * @license + * lodash 4.0.1 (Custom Build) + * Build: `lodash exports="amd" iife="angular.module('ngLodash', []).constant('lodash', null).config(function ($provide) { %output% $provide.constant('lodash', _);});" --output build/ng-lodash.js` + * Copyright 2012-2016 The Dojo Foundation + * Based on Underscore.js 1.8.3 + * Copyright 2009-2016 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors + * Available under MIT license + */ +angular.module("ngLodash",[]).constant("lodash",null).config(["$provide",function(a){function b(a,b){return a.set(b[0],b[1]),a}function c(a,b){return a.add(b),a}function d(a,b,c){var d=c?c.length:0;switch(d){case 0:return a.call(b);case 1:return a.call(b,c[0]);case 2:return a.call(b,c[0],c[1]);case 3:return a.call(b,c[0],c[1],c[2])}return a.apply(b,c)}function e(a,b){for(var c=-1,d=a.length,e=-1,f=b.length,g=Array(d+f);++c-1}function k(a,b,c){for(var d=-1,e=a.length;++d-1;);return c}function C(a,b){for(var c=a.length;c--&&t(b,a[c],0)>-1;);return c}function D(a){return a&&a.Object===Object?a:null}function E(a,b){if(a!==b){var c=null===a,d=a===U,e=a===a,f=null===b,g=b===U,h=b===b;if(a>b&&!f||!e||c&&!g&&h||d&&h)return 1;if(b>a&&!c||!h||f&&!d&&e||g&&e)return-1}return 0}function F(a,b,c){for(var d=-1,e=a.criteria,f=b.criteria,g=e.length,h=c.length;++d=h)return i;var j=c[d];return i*("desc"==j?-1:1)}}return a.index-b.index}function G(a){return pc[a]}function H(a){return qc[a]}function I(a){return"\\"+tc[a]}function J(a,b,c){for(var d=a.length,e=b+(c?0:-1);c?e--:++e-1&&a%1==0&&b>a}function M(a){for(var b,c=[];!(b=a.next()).done;)c.push(b.value);return c}function N(a){var b=-1,c=Array(a.size);return a.forEach(function(a,d){c[++b]=[d,a]}),c}function O(a,b){for(var c=-1,d=a.length,e=-1,f=[];++cb,e=c?a.length:0,f=be(0,e,this.__views__),g=f.start,h=f.end,i=h-g,j=d?h:g-1,k=this.__iteratees__,l=k.length,m=0,n=Vi(i,this.__takeCount__);if(!c||ka>e||e==i&&n==i)return pd(a,this.__actions__);var o=[];a:for(;i--&&n>m;){j+=b;for(var p=-1,q=a[j];++pc)return!1;var d=a.length-1;return c==d?a.pop():Oi.call(a,c,1),!0}function Yb(a,b){var c=$b(a,b);return 0>c?U:a[c][1]}function Zb(a,b){return $b(a,b)>-1}function $b(a,b){for(var c=a.length;c--;)if(eg(a[c][0],b))return c;return-1}function _b(a,b,c){var d=$b(a,b);0>d?a.push([b,c]):a[d][1]=c}function ac(a,b,c,d){return a===U||eg(a,wi[c])&&!yi.call(d,c)?b:a}function bc(a,b,c){(c!==U&&!eg(a[b],c)||"number"==typeof b&&c===U&&!(b in a))&&(a[b]=c)}function cc(a,b,c){var d=a[b];(!eg(d,c)||eg(d,wi[b])&&!yi.call(a,b)||c===U&&!(b in a))&&(a[b]=c)}function dc(a,b){return a&&Ad(b,fh(b),a)}function ec(a,b){for(var c=-1,d=null==a,e=b.length,f=Array(e);++c=a?a:c),b!==U&&(a=a>=b?a:b)),a}function pc(a,b,c,d,e,g){var h;if(c&&(h=e?c(a,d,e,g):c(a)),h!==U)return h;if(!vg(a))return a;var i=_j(a);if(i){if(h=de(a),!b)return zd(a,h)}else{var j=ae(a),k=j==Da||j==Ea;if(j!=Ha&&j!=ya&&(!k||e))return oc[j]?fe(a,j,b):e?a:{};if(K(a))return e?a:{};if(h=ee(k?{}:a),!b)return Cd(a,dc(h,a))}g||(g=new Rb);var l=g.get(a);return l?l:(g.set(a,h),(i?f:Ac)(a,function(d,e){cc(h,e,pc(d,b,c,e,a,g))}),i?h:Cd(a,h))}function qc(a){var b=fh(a),c=b.length;return function(d){if(null==d)return!c;for(var e=c;e--;){var f=b[e],g=a[f],h=d[f];if(h===U&&!(f in Object(d))||!g(h))return!1}return!0}}function rc(a,b,c){if("function"!=typeof a)throw new ui(oa);return Ni(function(){a.apply(U,c)},b)}function sc(a,b,c,d){var e=-1,f=j,g=!0,h=a.length,i=[],m=b.length;if(!h)return i;c&&(b=l(b,z(c))),d?(f=k,g=!1):b.length>=ka&&(f=Pb,g=!1,b=new Ob(b));a:for(;++ec&&(c=-c>e?0:e+c),d=d===U||d>e?e:Og(d),0>d&&(d+=e),d=c>d?0:Pg(d);d>c;)a[c++]=b;return a}function xc(a,b){var c=[];return jj(a,function(a,d,e){b(a,d,e)&&c.push(a)}),c}function yc(a,b,c,d){d||(d=[]);for(var e=-1,f=a.length;++ec;)a=a[b[c++]];return c&&c==d?a:U}function Gc(a,b){return yi.call(a,b)||"object"==typeof a&&b in a&&null===Ji(a)}function Hc(a,b){return b in Object(a)}function Ic(a,b,c){return a>=Vi(b,c)&&a=120)?new Ob(f&&i):U}i=a[0];var m=-1,n=i.length,o=g[0];a:for(;++m-1;)f!==a&&Oi.call(f,g,1),Oi.call(a,g,1);return a}function bd(a,b){for(var c=a?b.length:0,d=c-1;c--;){var e=b[c];if(d==c||e!=f){var f=e;if(L(e))Oi.call(a,e,1);else if(ie(e,a))delete a[e];else{var g=ld(e),h=pe(a,g);null!=h&&delete h[Me(g)]}}}return a}function cd(a,b){return a+Qi(Xi()*(b-a+1))}function dd(a,b,c,d){for(var e=-1,f=Ui(Pi((b-a)/(c||1)),0),g=Array(f);f--;)g[d?f:++e]=a,a+=c;return g}function ed(a,b,c,d){b=ie(b,a)?[b+""]:ld(b);for(var e=-1,f=b.length,g=f-1,h=a;null!=h&&++eb&&(b=-b>e?0:e+b),c=c>e?e:c,0>c&&(c+=e),e=b>c?0:c-b>>>0,b>>>=0;for(var f=Array(e);++d=e){for(;e>d;){var f=d+e>>>1,g=a[f];(c?b>=g:b>g)&&null!==g?d=f+1:e=f}return e}return id(a,b,Wh,c)}function id(a,b,c,d){b=c(b);for(var e=0,f=a?a.length:0,g=b!==b,h=null===b,i=b===U;f>e;){var j=Qi((e+f)/2),k=c(a[j]),l=k!==U,m=k===k;if(g)var n=m||d;else n=h?m&&l&&(d||null!=k):i?m&&(d||l):null==k?!1:d?b>=k:b>k;n?e=j+1:f=j}return Vi(f,va)}function jd(a){return kd(a)}function kd(a,b){for(var c=0,d=a.length,e=a[0],f=b?b(e):e,g=f,h=0,i=[e];++c=ka){var l=b?null:oj(a);if(l)return P(l);g=!1,e=Pb,i=new Ob}else i=b?[]:h;a:for(;++d1?c[e-1]:U,g=e>2?c[2]:U;for(f="function"==typeof f?(e--,f):U,g&&he(c[0],c[1],g)&&(f=3>e?U:f,e=1),b=Object(b);++dg&&i[0]!==k&&i[g-1]!==k?[]:O(i,k);return g-=l.length,c>g?Sd(a,b,Nd,k,U,i,l,U,U,c-g):d(j,this,i)}var f=Kd(a);return e}function Md(a){return Xf(function(b){b=yc(b);var c=b.length,d=c,e=vb.prototype.thru;for(a&&b.reverse();d--;){var f=b[d];if("function"!=typeof f)throw new ui(oa);if(e&&!g&&"wrapper"==Yd(f))var g=new vb([],!0)}for(d=g?d:c;++d=ka)return g.plant(d).value();for(var e=0,f=c?b[e].apply(this,a):d;++es)return Sd(a,b,Nd,v,c,u,w,h,i,j-s)}var x=m?c:this,y=n?x[a]:a;return h?u=qe(u,h):q&&u.length>1&&u.reverse(),l&&i=b)return"";var e=b-d;c=c===U?" ":c+"";var f=Gh(c,Pi(e/Q(c)));return hc.test(c)?R(f).slice(0,e).join(""):f.slice(0,e)}function Qd(a,b,c,e){function f(){for(var b=-1,i=arguments.length,j=-1,k=e.length,l=Array(k+i),m=this&&this!==Cc&&this instanceof f?h:a;++jb?1:-1:Qg(d)||0,dd(b,c,d,a)}}function Sd(a,b,c,d,e,f,g,h,i,j){var k=b&Z,l=h?zd(h):U,m=k?g:U,n=k?U:g,o=k?f:U,p=k?U:f;b|=k?_:aa,b&=~(k?aa:_),b&Y||(b&=~(W|X));var q=[a,b,e,o,m,p,n,l,i,j],r=c.apply(U,q);return ke(a)&&sj(r,q),r.placeholder=d,r}function Td(a){var b=si[a];return function(a,c){if(a=Qg(a),c=Og(c)){var d=(Tg(a)+"e").split("e"),e=b(d[0]+"e"+(+d[1]+c));return d=(Tg(e)+"e").split("e"),+(d[0]+"e"+(+d[1]-c))}return b(a)}}function Ud(a,b,c,d,e,f,g,h){var i=b&X;if(!i&&"function"!=typeof a)throw new ui(oa);var j=d?d.length:0;if(j||(b&=~(_|aa),d=e=U),g=g===U?g:Ui(Og(g),0),h=h===U?h:Og(h),j-=e?e.length:0,b&aa){var k=d,l=e;d=e=U}var m=i?U:pj(a),n=[a,b,c,d,e,k,l,f,g,h];if(m&&ne(n,m),a=n[0],b=n[1],c=n[2],d=n[3],e=n[4],h=n[9]=null==n[9]?i?0:a.length:Ui(n[9]-j,0),!h&&b&(Z|$)&&(b&=~(Z|$)),b&&b!=W)o=b==Z||b==$?Ld(a,b,h):b!=_&&b!=(W|_)||e.length?Nd.apply(U,n):Qd(a,b,c,d);else var o=Hd(a,b,c);var p=m?nj:sj;return p(o,n)}function Vd(a,b,c,d,e,f){var g=-1,h=e&fa,i=e&ea,j=a.length,k=b.length;if(j!=k&&!(h&&k>j))return!1;var l=f.get(a);if(l)return l==b;var m=!0;for(f.set(a,b);++ge,g=d==ba&&c==Z||d==ba&&c==ca&&a[7].length<=b[8]||d==(ba|ca)&&b[7].length<=b[8]&&c==Z;if(!f&&!g)return a;d&W&&(a[2]=b[2],e|=c&W?0:Y);var h=b[3];if(h){var i=a[3];a[3]=i?xd(i,h,b[4]):zd(h),a[4]=i?O(a[3],xa):zd(b[4])}return h=b[5],h&&(i=a[5],a[5]=i?yd(i,h,b[6]):zd(h),a[6]=i?O(a[5],xa):zd(b[6])),h=b[7],h&&(a[7]=zd(h)),d&ba&&(a[8]=null==a[8]?b[8]:Vi(a[8],b[8])),null==a[9]&&(a[9]=b[9]),a[0]=b[0],a[1]=e,a}function oe(a,b,c,d,e,f){return vg(a)&&vg(b)&&(f.set(b,a),Uc(a,b,U,oe,f)),a}function pe(a,b){return 1==b.length?a:bh(a,fd(b,0,-1))}function qe(a,b){for(var c=a.length,d=Vi(b.length,c),e=zd(a);d--;){var f=b[d];a[d]=L(f,c)?e[f]:U}return a}function re(a){var b=[];return Tg(a).replace(hb,function(a,c,d,e){b.push(d?e.replace(nb,"$1"):c||a)}),b}function se(a){return jg(a)?a:[]}function te(a){return"function"==typeof a?a:Wh}function ue(a){if(a instanceof zb)return a.clone();var b=new vb(a.__wrapped__,a.__chain__);return b.__actions__=zd(a.__actions__),b.__index__=a.__index__,b.__values__=a.__values__,b}function ve(a,b){b=Ui(Og(b),0);var c=a?a.length:0;if(!c||1>b)return[];for(var d=0,e=-1,f=Array(Pi(c/b));c>d;)f[++e]=fd(a,d,d+=b);return f}function we(a){for(var b=-1,c=a?a.length:0,d=-1,e=[];++bb?0:b,d)):[]}function ye(a,b,c){var d=a?a.length:0;return d?(b=c||b===U?1:Og(b),b=d-b,fd(a,0,0>b?0:b)):[]}function ze(a,b){return a&&a.length?od(a,Zd(b,3),!0,!0):[]}function Ae(a,b){return a&&a.length?od(a,Zd(b,3),!0):[]}function Be(a,b,c,d){var e=a?a.length:0;return e?(c&&"number"!=typeof c&&he(a,b,c)&&(c=0,d=e),wc(a,b,c,d)):[]}function Ce(a,b){return a&&a.length?s(a,Zd(b,3)):-1}function De(a,b){return a&&a.length?s(a,Zd(b,3),!0):-1}function Ee(a,b){var c=a?a.length:0;return c?yc(l(a,Zd(b,3))):[]}function Fe(a){var b=a?a.length:0;return b?yc(a):[]}function Ge(a){var b=a?a.length:0;return b?yc(a,!0):[]}function He(a){for(var b=-1,c=a?a.length:0,d={};++bc&&(c=Ui(d+c,0)),t(a,b,c)):-1}function Ke(a){return ye(a,1)}function Le(a,b){return a?Si.call(a,b):""}function Me(a){var b=a?a.length:0;return b?a[b-1]:U}function Ne(a,b,c){var d=a?a.length:0;if(!d)return-1;var e=d;if(c!==U&&(e=Og(c),e=(0>e?Ui(d+e,0):Vi(e,d-1))+1),b!==b)return J(a,e,!0);for(;e--;)if(a[e]===b)return e;return-1}function Oe(a,b){return a&&a.length&&b&&b.length?_c(a,b):a}function Pe(a,b,c){return a&&a.length&&b&&b.length?ad(a,b,Zd(c)):a}function Qe(a,b){var c=[];if(!a||!a.length)return c;var d=-1,e=[],f=a.length;for(b=Zd(b,3);++dd&&eg(a[d],b))return d}return-1}function We(a,b){return hd(a,b,!0)}function Xe(a,b,c){return id(a,b,Zd(c),!0)}function Ye(a,b){var c=a?a.length:0;if(c){var d=hd(a,b,!0)-1;if(eg(a[d],b))return d}return-1}function Ze(a){return a&&a.length?jd(a):[]}function $e(a,b){return a&&a.length?kd(a,Zd(b)):[]}function _e(a){return xe(a,1)}function af(a,b,c){return a&&a.length?(b=c||b===U?1:Og(b),fd(a,0,0>b?0:b)):[]}function bf(a,b,c){var d=a?a.length:0;return d?(b=c||b===U?1:Og(b),b=d-b,fd(a,0>b?0:b,d)):[]}function cf(a,b){return a&&a.length?od(a,Zd(b,3),!1,!0):[]}function df(a,b){return a&&a.length?od(a,Zd(b,3)):[]}function ef(a){return a&&a.length?md(a):[]}function ff(a,b){return a&&a.length?md(a,Zd(b)):[]}function gf(a,b){return a&&a.length?md(a,U,b):[]}function hf(a){if(!a||!a.length)return[];var b=0;return a=i(a,function(a){return jg(a)?(b=Ui(a.length,b),!0):void 0}),x(b,function(b){return l(a,Zc(b))})}function jf(a,b){if(!a||!a.length)return[];var c=hf(a);return null==b?c:l(c,function(a){return d(b,U,a)})}function kf(a,b){for(var c=-1,d=a?a.length:0,e=b?b.length:0,f={};++cc?b[c]:U);return f}function lf(a){var b=D(a);return b.__chain__=!0,b}function mf(a,b){return b(a),a}function nf(a,b){return b(a)}function of(){return lf(this)}function pf(){return new vb(this.value(),this.__chain__)}function qf(a){return this.map(a).flatten()}function rf(){this.__values__===U&&(this.__values__=Ng(this.value()));var a=this.__index__>=this.__values__.length,b=a?U:this.__values__[this.__index__++];return{done:a,value:b}}function sf(){return this}function tf(a){for(var b,c=this;c instanceof Ma;){var d=ue(c);d.__index__=0,d.__values__=U,b?e.__wrapped__=d:b=d;var e=d;c=c.__wrapped__}return e.__wrapped__=a,b}function uf(){var a=this.__wrapped__;if(a instanceof zb){var b=a;return this.__actions__.length&&(b=new zb(this)),b=b.reverse(),b.__actions__.push({func:nf,args:[Re],thisArg:U}),new vb(b,this.__chain__)}return this.thru(Re)}function vf(){return pd(this.__wrapped__,this.__actions__)}function wf(a,b,c){var d=_j(a)?h:tc;return c&&he(a,b,c)&&(b=U),d(a,Zd(b,3))}function xf(a,b){var c=_j(a)?i:xc;return c(a,Zd(b,3))}function yf(a,b){if(b=Zd(b,3),_j(a)){var c=s(a,b);return c>-1?a[c]:U}return r(a,b,jj)}function zf(a,b){if(b=Zd(b,3),_j(a)){var c=s(a,b,!0);return c>-1?a[c]:U}return r(a,b,kj)}function Af(a,b){return"function"==typeof b&&_j(a)?f(a,b):jj(a,te(b))}function Bf(a,b){return"function"==typeof b&&_j(a)?g(a,b):kj(a,te(b))}function Cf(a,b,c,d){a=ig(a)?a:sh(a),c=c&&!d?Og(c):0;var e=a.length;return 0>c&&(c=Ui(e+c,0)),Hg(a)?e>=c&&a.indexOf(b,c)>-1:!!e&&t(a,b,c)>-1}function Df(a,b){var c=_j(a)?l:Rc;return c(a,Zd(b,3))}function Ef(a,b,c,d){return null==a?[]:(_j(b)||(b=null==b?[]:[b]),c=d?U:c,_j(c)||(c=null==c?[]:[c]),Wc(a,b,c))}function Ff(a,b,c){var d=_j(a)?n:u,e=arguments.length<3;return d(a,Zd(b,4),c,e,jj)}function Gf(a,b,c){var d=_j(a)?o:u,e=arguments.length<3;return d(a,Zd(b,4),c,e,kj)}function Hf(a,b){var c=_j(a)?i:xc;return b=Zd(b,3),c(a,function(a,c,d){return!b(a,c,d)})}function If(a){var b=ig(a)?a:sh(a),c=b.length;return c>0?b[cd(0,c-1)]:U}function Jf(a,b){var c=-1,d=Ng(a),e=d.length,f=e-1;for(b=gc(Og(b),0,e);++c0&&(c=b.apply(this,arguments)),1>=a&&(b=U),c}}function Qf(a,b,c){b=c?U:b;var d=Ud(a,Z,U,U,U,U,U,b);return d.placeholder=Qf.placeholder,d}function Rf(a,b,c){b=c?U:b;var d=Ud(a,$,U,U,U,U,U,b);return d.placeholder=Rf.placeholder,d}function Sf(a,b,c){function d(){o&&Hi(o),k&&Hi(k),q=0,j=k=n=o=p=U}function e(b,c){c&&Hi(c),k=o=p=U,b&&(q=Sj(),l=a.apply(n,j),o||k||(j=n=U))}function f(){var a=b-(Sj()-m);0>=a||a>b?e(p,k):o=Ni(f,a)}function g(){return(o&&p||k&&t)&&(l=a.apply(n,j)),d(),l}function h(){e(t,o)}function i(){if(j=arguments,m=Sj(),n=this,p=t&&(o||!r),s===!1)var c=r&&!o;else{k||r||(q=m);var d=s-(m-q),e=0>=d||d>s;e?(k&&(k=Hi(k)),q=m,l=a.apply(n,j)):k||(k=Ni(h,d))}return e&&o?o=Hi(o):o||b===s||(o=Ni(f,b)),c&&(e=!0,l=a.apply(n,j)),!e||o||k||(j=n=U),l}var j,k,l,m,n,o,p,q=0,r=!1,s=!1,t=!0;if("function"!=typeof a)throw new ui(oa);return b=Qg(b)||0,vg(c)&&(r=!!c.leading,s="maxWait"in c&&Ui(Qg(c.maxWait)||0,b),t="trailing"in c?!!c.trailing:t),i.cancel=d,i.flush=g,i}function Tf(a){return Ud(a,da)}function Uf(a,b){if("function"!=typeof a||b&&"function"!=typeof b)throw new ui(oa);var c=function(){var d=arguments,e=b?b.apply(this,d):d[0],f=c.cache;if(f.has(e))return f.get(e);var g=a.apply(this,d);return c.cache=f.set(e,g),g};return c.cache=new Uf.Cache,c}function Vf(a){if("function"!=typeof a)throw new ui(oa);return function(){return!a.apply(this,arguments)}}function Wf(a){return Pf(2,a)}function Xf(a,b){if("function"!=typeof a)throw new ui(oa);return b=Ui(b===U?a.length-1:Og(b),0),function(){for(var c=arguments,e=-1,f=Ui(c.length-b,0),g=Array(f);++eb}function gg(a,b){return a>=b}function hg(a){return jg(a)&&yi.call(a,"callee")&&(!Mi.call(a,"callee")||Bi.call(a)==ya)}function ig(a){return null!=a&&!("function"==typeof a&&sg(a))&&ug(qj(a))}function jg(a){return wg(a)&&ig(a)}function kg(a){return a===!0||a===!1||wg(a)&&Bi.call(a)==Aa}function lg(a){return wg(a)&&Bi.call(a)==Ba}function mg(a){return!!a&&1===a.nodeType&&wg(a)&&!Eg(a)}function ng(a){return!wg(a)||sg(a.splice)?!Lf(a):!fh(a).length}function og(a,b){return Lc(a,b)}function pg(a,b,c){c="function"==typeof c?c:U;var d=c?c(a,b):U;return d===U?Lc(a,b,c):!!d}function qg(a){return wg(a)&&"string"==typeof a.message&&Bi.call(a)==Ca}function rg(a){return"number"==typeof a&&Ri(a)}function sg(a){var b=vg(a)?Bi.call(a):"";return b==Da||b==Ea}function tg(a){return"number"==typeof a&&a==Og(a)}function ug(a){return"number"==typeof a&&a>-1&&a%1==0&&ra>=a}function vg(a){var b=typeof a;return!!a&&("object"==b||"function"==b)}function wg(a){return!!a&&"object"==typeof a}function xg(a,b){return a===b||Nc(a,b,$d(b))}function yg(a,b,c){return c="function"==typeof c?c:U,Nc(a,b,$d(b),c)}function zg(a){return Dg(a)&&a!=+a}function Ag(a){return null==a?!1:sg(a)?Di.test(xi.call(a)):wg(a)&&(K(a)?Di:tb).test(a)}function Bg(a){return null===a}function Cg(a){return null==a}function Dg(a){return"number"==typeof a||wg(a)&&Bi.call(a)==Ga}function Eg(a){if(!wg(a)||Bi.call(a)!=Ha||K(a))return!1;var b=wi;if("function"==typeof a.constructor&&(b=Ji(a)),null===b)return!0;var c=b.constructor;return"function"==typeof c&&c instanceof c&&xi.call(c)==Ai}function Fg(a){return vg(a)&&Bi.call(a)==Ia}function Gg(a){return tg(a)&&a>=-ra&&ra>=a}function Hg(a){return"string"==typeof a||!_j(a)&&wg(a)&&Bi.call(a)==Ka}function Ig(a){return"symbol"==typeof a||wg(a)&&Bi.call(a)==La}function Jg(a){return wg(a)&&ug(a.length)&&!!nc[Bi.call(a)]}function Kg(a){return a===U}function Lg(a,b){return b>a}function Mg(a,b){return b>=a}function Ng(a){if(!a)return[];if(ig(a))return Hg(a)?R(a):zd(a);if(Li&&a[Li])return M(a[Li]());var b=ae(a),c=b==Fa?N:b==Ja?P:sh;return c(a)}function Og(a){if(!a)return 0===a?a:0;if(a=Qg(a),a===qa||a===-qa){var b=0>a?-1:1;return b*sa}var c=a%1;return a===a?c?a-c:a:0}function Pg(a){return a?gc(Og(a),0,ua):0}function Qg(a){if(vg(a)){var b=sg(a.valueOf)?a.valueOf():a;a=vg(b)?b+"":b}if("string"!=typeof a)return 0===a?a:+a;a=a.replace(kb,"");var c=sb.test(a);return c||ub.test(a)?vc(a.slice(2),c?2:8):rb.test(a)?ta:+a}function Rg(a){return Ad(a,gh(a))}function Sg(a){return gc(Og(a),-ra,ra)}function Tg(a){if("string"==typeof a)return a;if(null==a)return"";if(Ig(a))return Fi?gj.call(a):"";var b=a+"";return"0"==b&&1/a==-qa?"-0":b}function Ug(a,b){var c=ij(a);return b?dc(c,b):c}function Vg(a,b){return r(a,Zd(b,3),Ac,!0)}function Wg(a,b){return r(a,Zd(b,3),Bc,!0)}function Xg(a,b){return null==a?a:lj(a,te(b),gh)}function Yg(a,b){return null==a?a:mj(a,te(b),gh)}function Zg(a,b){return a&&Ac(a,te(b))}function $g(a,b){return a&&Bc(a,te(b))}function _g(a){return null==a?[]:Ec(a,fh(a))}function ah(a){return null==a?[]:Ec(a,gh(a))}function bh(a,b,c){var d=null==a?U:Fc(a,b);return d===U?c:d}function ch(a,b){return ce(a,b,Gc)}function dh(a,b){return ce(a,b,Hc)}function eh(a,b,c){return n(fh(a),function(d,e){var f=a[e];return b&&!c?yi.call(d,f)?d[f].push(e):d[f]=[e]:d[f]=e,d},{})}function fh(a){var b=le(a);if(!b&&!ig(a))return Pc(a);var c=ge(a),d=!!c,e=c||[],f=e.length;for(var g in a)!Gc(a,g)||d&&("length"==g||L(g,f))||b&&"constructor"==g||e.push(g);return e}function gh(a){for(var b=-1,c=le(a),d=Qc(a),e=d.length,f=ge(a),g=!!f,h=f||[],i=h.length;++bb){var d=a;a=b,b=d}if(c||a%1||b%1){var e=Xi();return Vi(a+e*(b-a+uc("1e-"+((e+"").length-1))),b)}return cd(a,b)}function xh(a){return qk(Tg(a).toLowerCase())}function yh(a){return a=Tg(a),a&&a.replace(wb,G).replace(fc,"")}function zh(a,b,c){a=Tg(a),b="string"==typeof b?b:b+"";var d=a.length;return c=c===U?d:gc(Og(c),0,d),c-=b.length,c>=0&&a.indexOf(b,c)==c}function Ah(a){return a=Tg(a),a&&bb.test(a)?a.replace(_a,H):a}function Bh(a){return a=Tg(a),a&&jb.test(a)?a.replace(ib,"\\$&"):a}function Ch(a,b,c){a=Tg(a),b=Og(b);var d=Q(a);if(!b||d>=b)return a;var e=(b-d)/2,f=Qi(e),g=Pi(e);return Pd("",f,c)+a+Pd("",g,c)}function Dh(a,b,c){return a=Tg(a),a+Pd(a,b,c)}function Eh(a,b,c){return a=Tg(a),Pd(a,b,c)+a}function Fh(a,b,c){return c||null==b?b=0:b&&(b=+b),a=Tg(a).replace(kb,""),Wi(a,b||(qb.test(a)?16:10))}function Gh(a,b){a=Tg(a),b=Og(b);var c="";if(!a||1>b||b>ra)return c;do b%2&&(c+=a),b=Qi(b/2),a+=a;while(b);return c}function Hh(){var a=arguments,b=Tg(a[0]);return a.length<3?b:b.replace(a[1],a[2])}function Ih(a,b,c){return Tg(a).split(b,c)}function Jh(a,b,c){return a=Tg(a),c=gc(Og(c),0,a.length),a.lastIndexOf(b,c)==c}function Kh(a,b,c){var d=D.templateSettings;c&&he(a,b,c)&&(b=U),a=Tg(a),b=ck({},b,d,ac);var e=ck({},b.imports,d.imports,ac),f=fh(e),g=A(e,f),h,i,j=0,k=b.interpolate||xb,l="__p += '",m=ti((b.escape||xb).source+"|"+k.source+"|"+(k===eb?ob:xb).source+"|"+(b.evaluate||xb).source+"|$","g"),n="//# sourceURL="+("sourceURL"in b?b.sourceURL:"lodash.templateSources["+ ++mc+"]")+"\n";a.replace(m,function(b,c,d,e,f,g){return d||(d=e),l+=a.slice(j,g).replace(yb,I),c&&(h=!0,l+="' +\n__e("+c+") +\n'"),f&&(i=!0,l+="';\n"+f+";\n__p += '"),d&&(l+="' +\n((__t = ("+d+")) == null ? '' : __t) +\n'"),j=g+b.length,b}),l+="';\n";var o=b.variable;o||(l="with (obj) {\n"+l+"\n}\n"),l=(i?l.replace(Xa,""):l).replace(Ya,"$1").replace(Za,"$1;"),l="function("+(o||"obj")+") {\n"+(o?"":"obj || (obj = {});\n")+"var __t, __p = ''"+(h?", __e = _.escape":"")+(i?", __j = Array.prototype.join;\nfunction print() { __p += __j.call(arguments, '') }\n":";\n")+l+"return __p\n}";var p=uk(function(){return Function(f,n+"return "+l).apply(U,g)});if(p.source=l,qg(p))throw p;return p}function Lh(a){return Tg(a).toLowerCase()}function Mh(a){return Tg(a).toUpperCase()}function Nh(a,b,c){if(a=Tg(a),!a)return a;if(c||b===U)return a.replace(kb,"");if(b+="",!b)return a;var d=R(a),e=R(b);return d.slice(B(d,e),C(d,e)+1).join("")}function Oh(a,b,c){if(a=Tg(a),!a)return a;if(c||b===U)return a.replace(mb,"");if(b+="",!b)return a;var d=R(a);return d.slice(0,C(d,R(b))+1).join("")}function Ph(a,b,c){if(a=Tg(a),!a)return a;if(c||b===U)return a.replace(lb,"");if(b+="",!b)return a;var d=R(a);return d.slice(B(d,R(b))).join("")}function Qh(a,b){var c=ga,d=ha;if(vg(b)){var e="separator"in b?b.separator:e;c="length"in b?Og(b.length):c,d="omission"in b?Tg(b.omission):d}a=Tg(a);var f=a.length;if(hc.test(a)){var g=R(a);f=g.length}if(c>=f)return a;var h=c-Q(d);if(1>h)return d;var i=g?g.slice(0,h).join(""):a.slice(0,h);if(e===U)return i+d;if(g&&(h+=i.length-h),Fg(e)){if(a.slice(h).search(e)){var j,k=i;for(e.global||(e=ti(e.source,Tg(pb.exec(e))+"g")),e.lastIndex=0;j=e.exec(k);)var l=j.index;i=i.slice(0,l===U?h:l)}}else if(a.indexOf(e,h)!=h){var m=i.lastIndexOf(e);m>-1&&(i=i.slice(0,m))}return i+d}function Rh(a){return a=Tg(a),a&&ab.test(a)?a.replace($a,S):a}function Sh(a,b,c){return a=Tg(a),b=c?U:b,b===U&&(b=kc.test(a)?jc:ic),a.match(b)||[]}function Th(a){var b=a?a.length:0,c=Zd();return a=b?l(a,function(a){if("function"!=typeof a[1])throw new ui(oa);return[c(a[0]),a[1]]}):[],Xf(function(c){for(var e=-1;++ea||a>ra)return[];var c=ua,d=Vi(a,ua);b=te(b),a-=ua;for(var e=x(d,b);++c0){if(++a>=ia)return c}else a=0;return nj(c,d)}}(),tj=Xf(function(a,b){return _j(a)||(a=null==a?[]:[Object(a)]),b=yc(b),e(a,b)}),uj=Xf(function(a,b){return jg(a)?sc(a,yc(b,!1,!0)):[]}),vj=Xf(function(a,b){var c=Me(b);return jg(c)&&(c=U),jg(a)?sc(a,yc(b,!1,!0),Zd(c)):[]}),wj=Xf(function(a,b){var c=Me(b);return jg(c)&&(c=U),jg(a)?sc(a,yc(b,!1,!0),U,c):[]}),xj=Xf(function(a){var b=l(a,se);return b.length&&b[0]===a[0]?Jc(b):[]}),yj=Xf(function(a){var b=Me(a),c=l(a,se);return b===Me(c)?b=U:c.pop(),c.length&&c[0]===a[0]?Jc(c,Zd(b)):[]}),zj=Xf(function(a){var b=Me(a),c=l(a,se);return b===Me(c)?b=U:c.pop(),c.length&&c[0]===a[0]?Jc(c,U,b):[]}),Aj=Xf(Oe),Bj=Xf(function(a,b){b=l(yc(b),String);var c=ec(a,b);return bd(a,b.sort(E)),c}),Cj=Xf(function(a){return md(yc(a,!1,!0))}),Dj=Xf(function(a){var b=Me(a);return jg(b)&&(b=U),md(yc(a,!1,!0),Zd(b))}),Ej=Xf(function(a){var b=Me(a);return jg(b)&&(b=U),md(yc(a,!1,!0),U,b)}),Fj=Xf(function(a,b){return jg(a)?sc(a,b):[]}),Gj=Xf(function(a){return qd(i(a,jg))}),Hj=Xf(function(a){var b=Me(a);return jg(b)&&(b=U),qd(i(a,jg),Zd(b))}),Ij=Xf(function(a){var b=Me(a);return jg(b)&&(b=U),qd(i(a,jg),U,b)}),Jj=Xf(hf),Kj=Xf(function(a){var b=a.length,c=b>1?a[b-1]:U;return c="function"==typeof c?(a.pop(),c):U,jf(a,c)}),Lj=Xf(function(a){a=yc(a);var b=a.length,c=b?a[0]:0,d=this.__wrapped__,e=function(b){return ec(b,a)};return!(b>1||this.__actions__.length)&&d instanceof zb&&L(c)?(d=d.slice(c,+c+(b?1:0)),d.__actions__.push({func:nf,args:[e],thisArg:U}),new vb(d,this.__chain__).thru(function(a){return b&&!a.length&&a.push(U),a})):this.thru(e)}),Mj=Dd(function(a,b,c){yi.call(a,c)?++a[c]:a[c]=1}),Nj=Dd(function(a,b,c){yi.call(a,c)?a[c].push(b):a[c]=[b]}),Oj=Xf(function(a,b,c){var e=-1,f="function"==typeof b,g=ie(b),h=ig(a)?Array(a.length):[];return jj(a,function(a){var i=f?b:g&&null!=a?a[b]:U;h[++e]=i?d(i,a,c):Kc(a,b,c)}),h}),Pj=Dd(function(a,b,c){a[c]=b}),Qj=Dd(function(a,b,c){a[c?0:1].push(b)},function(){return[[],[]]}),Rj=Xf(function(a,b){if(null==a)return[];var c=b.length;return c>1&&he(a,b[0],b[1])?b=[]:c>2&&he(b[0],b[1],b[2])&&(b.length=1),Wc(a,yc(b),[])}),Sj=qi.now,Tj=Xf(function(a,b,c){var d=W;if(c.length){var e=O(c,Tj.placeholder);d|=_}return Ud(a,d,b,c,e)}),Uj=Xf(function(a,b,c){var d=W|X;if(c.length){var e=O(c,Uj.placeholder);d|=_}return Ud(b,d,a,c,e)}),Vj=Xf(function(a,b){return rc(a,1,b)}),Wj=Xf(function(a,b,c){return rc(a,Qg(b)||0,c)}),Xj=Xf(function(a,b){b=l(yc(b),Zd());var c=b.length;return Xf(function(e){for(var f=-1,g=Vi(e.length,c);++f0||0>b)?new zb(c):(0>a?c=c.takeRight(-a):a&&(c=c.drop(a)),b!==U&&(b=Og(b),c=0>b?c.dropRight(-b):c.take(b-a)),c)},zb.prototype.takeRightWhile=function(a){return this.reverse().takeWhile(a).reverse()},zb.prototype.toArray=function(){return this.take(ua)},Ac(zb.prototype,function(a,b){var c=/^(?:filter|find|map|reject)|While$/.test(b),d=/^(?:head|last)$/.test(b),e=D[d?"take"+("last"==b?"Right":""):b],f=d||/^find/.test(b);e&&(D.prototype[b]=function(){var b=this.__wrapped__,g=d?[1]:arguments,h=b instanceof zb,i=g[0],j=h||_j(b),k=function(a){var b=e.apply(D,m([a],g));return d&&l?b[0]:b};j&&c&&"function"==typeof i&&1!=i.length&&(h=j=!1);var l=this.__chain__,n=!!this.__actions__.length,o=f&&!l,p=h&&!n;if(!f&&j){b=p?b:new zb(this);var q=a.apply(b,g);return q.__actions__.push({func:nf,args:[k],thisArg:U}),new vb(q,l)}return o&&p?a.apply(this,g):(q=this.thru(k),o?d?q.value()[0]:q.value():q)})}),f(["pop","push","shift","sort","splice","unshift"],function(a){var b=vi[a],c=/^(?:push|sort|unshift)$/.test(a)?"tap":"thru",d=/^(?:pop|shift)$/.test(a);D.prototype[a]=function(){var a=arguments;return d&&!this.__chain__?b.apply(this.value(),a):this[c](function(c){return b.apply(c,a)})}}),Ac(zb.prototype,function(a,b){var c=D[b];if(c){var d=c.name+"",e=hj[d]||(hj[d]=[]);e.push({name:b,func:c})}}),hj[Nd(U,X).name]=[{name:"wrapper",func:U}],zb.prototype.clone=Ab,zb.prototype.reverse=Bb,zb.prototype.value=Cb,D.prototype.at=Lj,D.prototype.chain=of,D.prototype.commit=pf,D.prototype.flatMap=qf,D.prototype.next=rf,D.prototype.plant=tf,D.prototype.reverse=uf,D.prototype.toJSON=D.prototype.valueOf=D.prototype.value=vf,Li&&(D.prototype[Li]=sf),D}var U,V="4.0.1",W=1,X=2,Y=4,Z=8,$=16,_=32,aa=64,ba=128,ca=256,da=512,ea=1,fa=2,ga=30,ha="...",ia=150,ja=16,ka=200,la=1,ma=2,na=3,oa="Expected a function",pa="__lodash_hash_undefined__",qa=1/0,ra=9007199254740991,sa=1.7976931348623157e308,ta=NaN,ua=4294967295,va=ua-1,wa=ua>>>1,xa="__lodash_placeholder__",ya="[object Arguments]",za="[object Array]",Aa="[object Boolean]",Ba="[object Date]",Ca="[object Error]",Da="[object Function]",Ea="[object GeneratorFunction]",Fa="[object Map]",Ga="[object Number]",Ha="[object Object]",Ia="[object RegExp]",Ja="[object Set]",Ka="[object String]",La="[object Symbol]",Ma="[object WeakMap]",Na="[object ArrayBuffer]",Oa="[object Float32Array]",Pa="[object Float64Array]",Qa="[object Int8Array]",Ra="[object Int16Array]",Sa="[object Int32Array]",Ta="[object Uint8Array]",Ua="[object Uint8ClampedArray]",Va="[object Uint16Array]",Wa="[object Uint32Array]",Xa=/\b__p \+= '';/g,Ya=/\b(__p \+=) '' \+/g,Za=/(__e\(.*?\)|\b__t\)) \+\n'';/g,$a=/&(?:amp|lt|gt|quot|#39|#96);/g,_a=/[&<>"'`]/g,ab=RegExp($a.source),bb=RegExp(_a.source),cb=/<%-([\s\S]+?)%>/g,db=/<%([\s\S]+?)%>/g,eb=/<%=([\s\S]+?)%>/g,fb=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,gb=/^\w*$/,hb=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]/g,ib=/[\\^$.*+?()[\]{}|]/g,jb=RegExp(ib.source),kb=/^\s+|\s+$/g,lb=/^\s+/,mb=/\s+$/,nb=/\\(\\)?/g,ob=/\$\{([^\\}]*(?:\\.[^\\}]*)*)\}/g,pb=/\w*$/,qb=/^0x/i,rb=/^[-+]0x[0-9a-f]+$/i,sb=/^0b[01]+$/i,tb=/^\[object .+?Constructor\]$/,ub=/^0o[0-7]+$/i,vb=/^(?:0|[1-9]\d*)$/,wb=/[\xc0-\xd6\xd8-\xde\xdf-\xf6\xf8-\xff]/g,xb=/($^)/,yb=/['\n\r\u2028\u2029\\]/g,zb="\\ud800-\\udfff",Ab="\\u0300-\\u036f\\ufe20-\\ufe23",Bb="\\u20d0-\\u20f0",Cb="\\u2700-\\u27bf",Db="a-z\\xdf-\\xf6\\xf8-\\xff",Eb="\\xac\\xb1\\xd7\\xf7",Fb="\\x00-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\xbf",Gb="\\u2018\\u2019\\u201c\\u201d",Hb=" \\t\\x0b\\f\\xa0\\ufeff\\n\\r\\u2028\\u2029\\u1680\\u180e\\u2000\\u2001\\u2002\\u2003\\u2004\\u2005\\u2006\\u2007\\u2008\\u2009\\u200a\\u202f\\u205f\\u3000",Ib="A-Z\\xc0-\\xd6\\xd8-\\xde",Jb="\\ufe0e\\ufe0f",Kb=Eb+Fb+Gb+Hb,Lb="["+zb+"]",Mb="["+Kb+"]",Nb="["+Ab+Bb+"]",Ob="\\d+",Pb="["+Cb+"]",Qb="["+Db+"]",Rb="[^"+zb+Kb+Ob+Cb+Db+Ib+"]",Sb="\\ud83c[\\udffb-\\udfff]",Tb="(?:"+Nb+"|"+Sb+")",Ub="[^"+zb+"]",Vb="(?:\\ud83c[\\udde6-\\uddff]){2}",Wb="[\\ud800-\\udbff][\\udc00-\\udfff]",Xb="["+Ib+"]",Yb="\\u200d",Zb="(?:"+Qb+"|"+Rb+")",$b="(?:"+Xb+"|"+Rb+")",_b=Tb+"?",ac="["+Jb+"]?",bc="(?:"+Yb+"(?:"+[Ub,Vb,Wb].join("|")+")"+ac+_b+")*",cc=ac+_b+bc,dc="(?:"+[Pb,Vb,Wb].join("|")+")"+cc,ec="(?:"+[Ub+Nb+"?",Nb,Vb,Wb,Lb].join("|")+")",fc=RegExp(Nb,"g"),gc=RegExp(Sb+"(?="+Sb+")|"+ec+cc,"g"),hc=RegExp("["+Yb+zb+Ab+Bb+Jb+"]"),ic=/[a-zA-Z0-9]+/g,jc=RegExp([Xb+"?"+Qb+"+(?="+[Mb,Xb,"$"].join("|")+")",$b+"+(?="+[Mb,Xb+Zb,"$"].join("|")+")",Xb+"?"+Zb+"+",Xb+"+",Ob,dc].join("|"),"g"),kc=/[a-z][A-Z]|[0-9][a-zA-Z]|[a-zA-Z][0-9]|[^a-zA-Z0-9 ]/,lc=["Array","Date","Error","Float32Array","Float64Array","Function","Int8Array","Int16Array","Int32Array","Map","Math","Object","Reflect","RegExp","Set","String","Symbol","TypeError","Uint8Array","Uint8ClampedArray","Uint16Array","Uint32Array","WeakMap","_","clearTimeout","isFinite","parseInt","setTimeout"],mc=-1,nc={};nc[Oa]=nc[Pa]=nc[Qa]=nc[Ra]=nc[Sa]=nc[Ta]=nc[Ua]=nc[Va]=nc[Wa]=!0,nc[ya]=nc[za]=nc[Na]=nc[Aa]=nc[Ba]=nc[Ca]=nc[Da]=nc[Fa]=nc[Ga]=nc[Ha]=nc[Ia]=nc[Ja]=nc[Ka]=nc[Ma]=!1;var oc={};oc[ya]=oc[za]=oc[Na]=oc[Aa]=oc[Ba]=oc[Oa]=oc[Pa]=oc[Qa]=oc[Ra]=oc[Sa]=oc[Fa]=oc[Ga]=oc[Ha]=oc[Ia]=oc[Ja]=oc[Ka]=oc[La]=oc[Ta]=oc[Ua]=oc[Va]=oc[Wa]=!0,oc[Ca]=oc[Da]=oc[Ma]=!1;var pc={"À":"A","Ã":"A","Â":"A","Ã":"A","Ä":"A","Ã…":"A","à":"a","á":"a","â":"a","ã":"a","ä":"a","Ã¥":"a","Ç":"C","ç":"c","Ã":"D","ð":"d","È":"E","É":"E","Ê":"E","Ë":"E","è":"e","é":"e","ê":"e","ë":"e","ÃŒ":"I","Ã":"I","ÃŽ":"I","Ã":"I","ì":"i","í":"i","î":"i","ï":"i","Ñ":"N","ñ":"n","Ã’":"O","Ó":"O","Ô":"O","Õ":"O","Ö":"O","Ø":"O","ò":"o","ó":"o","ô":"o","õ":"o","ö":"o","ø":"o","Ù":"U","Ú":"U","Û":"U","Ãœ":"U","ù":"u","ú":"u","û":"u","ü":"u","Ã":"Y","ý":"y","ÿ":"y","Æ":"Ae","æ":"ae","Þ":"Th","þ":"th","ß":"ss"},qc={"&":"&","<":"<",">":">",'"':""","'":"'","`":"`"},rc={"&":"&","<":"<",">":">",""":'"',"'":"'","`":"`"},sc={"function":!0,object:!0},tc={"\\":"\\","'":"'","\n":"n","\r":"r","\u2028":"u2028","\u2029":"u2029"},uc=parseFloat,vc=parseInt,wc=sc[typeof exports]&&exports&&!exports.nodeType?exports:null,xc=sc[typeof module]&&module&&!module.nodeType?module:null,yc=D(wc&&xc&&"object"==typeof global&&global),zc=D(sc[typeof self]&&self),Ac=D(sc[typeof window]&&window),Bc=D(sc[typeof this]&&this),Cc=yc||Ac!==(Bc&&Bc.window)&&Ac||zc||Bc||Function("return this")(),Dc=T();(Ac||zc||{})._=Dc,"function"==typeof define&&"object"==typeof define.amd&&define.amd&&define(function(){return Dc}),a.constant("lodash",Dc)}]); \ No newline at end of file diff --git a/html/allsky/runCommands.php b/html/allsky/runCommands.php new file mode 100644 index 000000000..eb1b81b75 --- /dev/null +++ b/html/allsky/runCommands.php @@ -0,0 +1,330 @@ +#!/usr/bin/php + files.txt +*/ + +$commandFile = "commands.txt"; + +// Check sanity of Website and make necessary directories. +// This file is normally accessed via curl, not a web page, +// so only use html in output when invoked from a web page. +// The list of "commands" to run are in $commandFile, one command per line, +// with optional tab-separated arguments. + +$TAB = "\t"; +$NL = "\n"; + +if (isset($_SERVER['HTTP_USER_AGENT']) && + strpos($_SERVER['HTTP_USER_AGENT'], "curl") === false) { + $NL = "
"; +} + +if (isset($_GET["debug"])) { + $debug = true; +} else { + $debug = false; +} + +if (! file_exists($commandFile)) { + do_error("Command file '$commandFile' not found."); + exit(1); +} +$lines = file($commandFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); +//x unlink($commandFile); + +if ($lines === false) { + $last_error = error_get_last(); + do_error("Unable to read '$commandFile': ${last_error["message"]}."); + exit(1); +} + +$startrails_glob = "startrails-*.jpg"; +$keogram_glob = "keogram-*.jpg"; +$timelapse_glob = "allsky-*.mp4"; + +$ok = true; + +foreach ($lines AS $line) { + $command = strtok($line, $TAB); + if (substr($command, 0, 1) === "#") + continue; + + switch ($command) { + case "set": + if (($args = get_args(2, $command)) === null) + break; + + $variable = $args[0]; + if ($variable === "debug") { + $debug = $args[1]; + } else { + do_warning("'$command' variable '$variable' unknown; ignoring."); + } + break; + + + case "mkdir": + // The arguments are the directories to create. + if (($args = get_args(-1, $command)) === null) + break; + + $dirsCreated = ""; + $c = 0; + foreach ($args as $dir) { + if (is_dir($dir)) { + if ($debug) do_debug("'$dir' already exists."); + + } else { + if ($debug) do_debug("Making $dir."); + if (mkdir($dir, 0775, true)) { // "true" == recursive + $c++; + if ($dirsCreated !== "") $dirsCreated .= ", "; + $dirsCreated .= $dir; + } else { + $last_error = error_get_last(); + $err = $last_error["message"]; + do_error("Unable to make '$dir' directory: $err."); + } + } + } + + if ($c === 0) + $msg = "success"; + else + $msg = "Created: $dirsCreated"; + do_return($command, $args, $msg); + break; + + + case "get_numfiles": + // Return the number of files of the given type. + if (($args = get_args(-1, $command)) === null) + break; + + foreach ($args as $filetype) { + $numFiles = count_files($filetype); + do_return($command, array($filetype), $numFiles); + } + break; + + + case "do_daystokeep": // filetype TAB number + // Only keep up to $daystokeep images/videos + if (($args = get_args(2, $command)) === null) + break; + + $filetype = $args[0]; + $parent = basename(dirname($filetype)); + $daystokeep = $args[1]; + + $c = count_files($filetype); + $numToDelete = $c - $daystokeep; + if ($numToDelete > 0) { + // "true" to also delete the thumbnail. + $deleted = delete_files($filetype, $numToDelete, $command, $args, true); + if ($deleted > 0) { +//x this is redundant with displaying individual file names that were deleted. +//x do_return($command, $args, "Deleted $deleted old '$parent' files."); + } + } else if ($debug) do_debug("Only $c '$parent' files - not deleting any."); + break; + + + case "checksum": + // Check the checksum of the specified file. + if (($args = get_args(2, $command)) === null) + break; + + $file = $args[0]; + $checksum = $args[1] + 0; + if (! file_exists($file)) { + do_return($command, $args, "'$file' is missing."); + break; + } + if (crc32($file) !== $checksum) { + do_return($command, $args, "'$file' is different."); + } else if ($debug) do_debug("'$file' is same."); + break; + + + case "delete_prior_files": + // Delete files left over from prior Allsky releases. + $files = array("version", "README.md", "config.js"); + foreach ($files AS $file) { + if (file_exists($file)) { + if (unlink($file)) + do_return($command, "", "Deleted: $file"); + else + do_error("Unable to delete '$file'."); + } + } + + $dirs = array(".git"); + foreach ($dirs AS $dir) { + if (is_dir($dir) && deleteDirectory($dir)) { // recursively deletes + do_return($command, "", "Deleted: $dir/"); + } + } + break; + + + case "xxx": +// TODO: add other checks here + break; + + + default: + do_warning("Ignoring unknown command '$command'."); + break; + + } +} + +if (! $ok) exit(1); + + +// Helper functions. +function do_error($msg) { global $NL, $TAB; echo "ERROR${TAB}${msg}${NL}"; } +function do_warning($msg) { global $NL, $TAB; echo "WARNING${TAB}${msg}${NL}"; } +function do_debug($msg) { global $NL, $TAB; echo "DEBUG${TAB}${msg}${NL}"; } +function do_return($cmd, $a, $msg) { + global $NL, $TAB; + echo "RETURN${TAB}${cmd}"; + if (gettype($a) === "array") echo " " . implode(" ", $a); + echo "${TAB}${msg}${NL}"; +} + +// Get all the arguments and put in an array. +// Make sure there is at least $num arguments: +// 0 means arguments are optional +// negative numbers means AT LEAST that many arguments + +$args = null; +function get_args($num, $cmd) { + global $TAB, $NL, $args; + + if ($args !== null) + unset($args); // clear old array + + $args = array(); + + $arg = strtok($TAB); + $found = 0; + while ($arg !== false) { + $found++; + $args[] = $arg; + $arg = strtok($TAB); + } + + if ($found < abs($num)) { + if ($num < 0) + $x = "at least"; + else + $x = "exactly"; + + do_error("'$cmd' requires $x $num arguments; $found were given."); + return(null); + } + return($args); +} + +// Return the number of file of the specified type. +function count_files($filetype) { + $filelist = glob($filetype); + return(count($filelist)); +} + +// Delete the oldest $deleteNum files in $filesToDelete. +// Also delete the associated thumbnail. +function delete_files($filesToDelete, $deleteNum, $cmd, $a, $delete_thumbnail) { + global $debug; + + // Have to do with two commands, not one. + $files = array_diff(glob($filesToDelete), array('.','..')); + + // The files have the date in their name, so sort the list descending to + // know which to delete. + rsort($files); + $filesDeleted = ""; + $c = 0; + while ($deleteNum > 0) { + $file = $files[$deleteNum -1]; // -1 for 0 index offset + $deleteNum--; + + if ($debug) { + do_return($cmd, $a, "'$file' NOT deleted due to debug"); + } else { + if (! unlink($file)) { + $last_error = error_get_last(); + do_warning("Unable to remove '$file': ${last_error["message"]}."); + } else { + $c++; + do_return($cmd, $a, "'$file' deleted."); + + if ($filesDeleted !== "") $filesDeleted .= ", "; + $filesDeleted .= $file; + } + + if ($delete_thumbnail) { + // Now delete the thumbnail + $parent = dirname($file); + $fileName = basename($file); + $thumb = "$parent/thumbnails/$fileName"; + if (file_exists($thumb)) unlink($thumb); + } + } + } + + if ($c > 0) do_return($cmd, $a, "Deleted: $filesDeleted"); + return($c); +} + +// Delete the specified directory and all its contents. +function deleteDirectory($dir) { + if (! is_dir($dir)) + return(false); + + $files = @scandir($dir); + if ($files === false) { + $last_error = error_get_last(); + $err = $last_error["message"]; + do_error("Unable to scandir($dir): $err."); + return(false); + } + + $files = array_diff($files, array(".", "..")); + foreach ($files as $file) { + $filePath = $dir . '/' . $file; + if (is_dir($filePath)) { + deleteDirectory($filePath); + } else { + if (! @unlink($filePath)) { + $last_error = error_get_last(); + $err = $last_error["message"]; + do_error($err); + } + } + } + + $ret = @rmdir($dir); + if (! $ret) { + $last_error = error_get_last(); + $err = $last_error["message"]; + do_error($err); + } + + return($ret); +} + +?> diff --git a/html/allsky/show_thumbnails.php b/html/allsky/show_thumbnails.php new file mode 100644 index 000000000..2bdd85831 --- /dev/null +++ b/html/allsky/show_thumbnails.php @@ -0,0 +1,33 @@ +INTERNAL ERROR: incomplete arguments given to view thumbnails.

"; + echo "dir, prefix, and/or title missing."; + exit; + } + $homePage = v("homePage", null, $webSettings_array); + $includeGoogleAnalytics = v("includeGoogleAnalytics", false, $homePage); +?> + + + + + + + <?php echo $title; ?> + + +"; + } +?> + + + + + + + diff --git a/html/allsky/startrails/index.php b/html/allsky/startrails/index.php new file mode 100644 index 000000000..28b6fd84b --- /dev/null +++ b/html/allsky/startrails/index.php @@ -0,0 +1,6 @@ + diff --git a/html/allsky/videos/index.php b/html/allsky/videos/index.php new file mode 100644 index 000000000..1921f8e40 --- /dev/null +++ b/html/allsky/videos/index.php @@ -0,0 +1,6 @@ + diff --git a/html/allsky/viewSettings.php b/html/allsky/viewSettings.php new file mode 100644 index 000000000..9725d42c2 --- /dev/null +++ b/html/allsky/viewSettings.php @@ -0,0 +1,88 @@ + + + +"; + $settingsScript = "$vSDir/allskySettings.php"; + if (! file_exists($settingsScript)) { + echo "
"; + echo "This Allsky Website is not fully configured so its settings cannot be displayed."; + echo "
It is missing the '$settingsScript' file."; + echo "
"; + exit(1); + } + + // This gets the web page settings. + include_once('functions.php'); // Sets $webSettings_array + + function getSettingsFile() { global $vSDir; return "$vSDir/settings.json"; } + // Define simplified functions from the WebUI's includes/functions.php file. + function readSettingsFile() { + $settings_file = getSettingsFile(); + $errorMsg = "ERROR: Unable to process settings file '$settings_file'."; + $contents = get_decoded_json_file($settings_file, true, $errorMsg); + if ($contents === null) { + exit(1); + } + return($contents); + } + function getOptionsFile() { global $vSDir; return "$vSDir/options.json"; } + function getVariableOrDefault($a, $v, $d) { return v($v, $d, $a); } + function check_if_configured($page, $calledFrom) { return true; } + function CSRFToken() { return true; } + function toBool($x) { if ($x == "true" || $x == "1" || $x == 1) return true; else return false; } + + $formReadonly = true; + $endSetting = "XX_END_XX"; + include_once($settingsScript); + + // Get home page options + $homePage = v("homePage", null, $webSettings_array); + $title = v("title", "Website", $homePage); + $favicon = v("favicon", "allsky-favicon.png", $homePage); + $ext = pathinfo($favicon, PATHINFO_EXTENSION); if ($ext === "jpg") $ext = "jpeg"; + $faviconType = "image/$ext"; + $backgroundImage = v("backgroundImage", "", $homePage); + if ($backgroundImage !== "") { + $backgroundImage_url = v("url", "", $backgroundImage); + if ($backgroundImage_url !== "") + $backgroundImage_style = v("style", "", $backgroundImage); + } +?> + + + Allsky Settings + + + + + + + + + + + + + diff --git a/html/allsky/viewSettings/README.txt b/html/allsky/viewSettings/README.txt new file mode 100644 index 000000000..e94f8997f --- /dev/null +++ b/html/allsky/viewSettings/README.txt @@ -0,0 +1 @@ +This directory holds settings-related files from the Pi so they can optionally be displayed in the Allsky Website. diff --git a/html/allsky/virtualsky/boundaries.json b/html/allsky/virtualsky/boundaries.json new file mode 100755 index 000000000..01dcac570 --- /dev/null +++ b/html/allsky/virtualsky/boundaries.json @@ -0,0 +1,93 @@ +{ + "boundaries": [ +["And",343,34.5,343,52.5,350,52.5,350,50,353.75,50,353.75,48,2.5,48,2.5,46,13,46,13,48,16.75,48,16.75,50,20.5,50,25,50,25,47,30.625,47,30.625,50.5,37.75,50.5,37.75,36.75,30,36.75,30,35,21.125,35,21.125,33,10.75,33,10.75,23.75,12.75,23.75,12.75,21,2.125,21,2.125,22,1,22,1,28,0,28,0,31.33333,356.25,31.33333,356.25,32.08333,352.5,32.08333,352.5,34.5,343,34.5], +["Ant",140.5,-24,146.25,-24,146.25,-26.5,153.75,-26.5,153.75,-29.16667,158.75,-29.16667,158.75,-31.16667,162.5,-31.16667,162.5,-35,165,-35,165,-39.75,140.5,-39.75,140.5,-36.75,140.5,-24], +["Aps",205,-82.5,205,-75,205,-70,221.25,-70,255,-70,255,-67.5,262.5,-67.5,270,-67.5,270,-75,270,-82.5,205,-82.5], +["Aqr",308,0,308,2,312.5,2,320,2,322,2,322,2.75,325,2.75,325,1.75,330,1.75,330,2,341.25,2,341.25,0,341.25,-4,357.5,-4,357.5,-7,357.5,-25.5,345,-25.5,328,-25.5,328,-9,320,-9,320,-15,308,-15,308,-9,308,0], +["Aql",278.75,0,278.75,2,283,2,283,6.25,279.933,6.25,279.933,12,283,12,283,18.5,285,18.5,285,16.16667,297.5,16.16667,297.5,15.75,302.125,15.75,302.125,8.5,304.5,8.5,304.5,2,308,2,308,0,308,-9,300,-9,300,-12.03333,283,-12.03333,283,-4,278.75,-4,278.75,0], +["Ara",246.312,-60,246.312,-45.5,267.5,-45.5,270,-45.5,270,-57,262.5,-57,262.5,-67.5,255,-67.5,252.5,-67.5,252.5,-65,251.25,-65,251.25,-63.58333,248.75,-63.58333,248.75,-61,246.312,-61,246.312,-60], +["Ari",30,9.91667,25,9.91667,25,25,28.75,25,28.75,27.25,36.25,27.25,36.25,30.66667,40.75,30.66667,50.5,30.66667,50.5,19,49.25,19,49.25,9.91667,30,9.91667], +["Aur",67.5,30.66667,67.5,36,70.375,36,70.375,52.5,75,52.5,75,56,91.5,56,91.5,54,97.5,54,97.5,50,102,50,102,44.5,110.5,44.5,110.5,35.5,98,35.5,98,28,88.25,28,88.25,28.5,71.25,28.5,71.25,30,67.5,30,67.5,30.66667], +["Boo",226.25,8,202.5,8,202.5,15,202.5,28.5,209.375,28.5,209.375,30.75,210.5,30.75,210.5,48.5,210.5,55.5,216.25,55.5,228.75,55.5,228.75,53,236.25,53,236.25,51.5,236.25,40,231.5,40,231.5,33,227.75,33,227.75,26,226.25,26,226.25,8], +["Cae",64,-40,64,-37,68.75,-37,68.75,-30,70.5,-30,70.5,-27.25,72.5,-27.25,75,-27.25,75,-43,72.5,-43,72.5,-46.5,67.5,-46.5,67.5,-49,64,-49,64,-40], +["Cam",91.5,56,75,56,75,52.5,70.375,52.5,50,52.5,50,55,47.5,55,47.5,57,46.5,57,46.5,68,51.25,68,51.25,77,52.625,77,52.625,80,75,80,75,85,120,85,120,86.5,217.5,86.5,217.5,80,203.75,80,203.75,77,195,77,172.5,77,172.5,80,160,80,160,82,137.5,82,137.5,73.5,119.5,73.5,119.5,60,105,60,105,62,91.5,62,91.5,56], +["Cnc",138.75,7,121.25,7,118.875,7,118.875,10,117.125,10,117.125,13.5,117.125,20,118.25,20,118.25,28,120,28,120,33.5,138.75,33.5,138.75,7], +["CVn",180,34,180,45,181.25,45,181.25,53,202.5,53,202.5,48.5,210.5,48.5,210.5,30.75,209.375,30.75,209.375,28.5,202.5,28.5,198.75,28.5,198.75,32,185,32,185,34,180,34], +["CMa",91.75,-11,110.5,-11,110.5,-33,98.75,-33,91.75,-33,91.75,-27.25,91.75,-11], +["CMi",121.25,0,108,0,108,1.5,105.25,1.5,105.25,5.5,105,5.5,105,10,105,12.5,112.5,12.5,112.5,13.5,117.125,13.5,117.125,10,118.875,10,118.875,7,121.25,7,121.25,0], +["Cap",308,-9,308,-15,320,-15,320,-9,328,-9,328,-25.5,320,-25.5,320,-28,305,-28,300,-28,300,-12.03333,300,-9,308,-9], +["Car",168.75,-56.5,168.75,-64,168.75,-75,135.5,-75,135.5,-64,102.5,-64,102.5,-58,97.5,-58,97.5,-55,92.5,-55,92.5,-52.5,90,-52.5,90,-50.75,120,-50.75,122.5,-50.75,122.5,-53,126.75,-53,126.75,-54.5,132.5,-54.5,132.5,-56.5,165,-56.5,168.75,-56.5], +["Cas",343,52.5,343,56.25,343,59.08333,347.5,59.08333,347.5,63,353.75,63,353.75,66,5,66,5,77,51.25,77,51.25,68,46.5,68,46.5,57,36.5,57,36.5,58.5,28.625,58.5,28.625,57.5,25.5,57.5,25.5,54,20.5,54,20.5,50,16.75,50,16.75,48,13,48,13,46,2.5,46,2.5,48,353.75,48,353.75,50,350,50,350,52.5,343,52.5], +["Cen",165,-35,183.75,-35,183.75,-33,188.75,-33,188.75,-29.5,223.75,-29.5,223.75,-42,212.5,-42,212.5,-55,218,-55,218,-64,202.5,-64,192.5,-64,192.5,-55,177.5,-55,177.5,-64,168.75,-64,168.75,-56.5,165,-56.5,165,-39.75,165,-35], +["Cep",300,59.5,300,61.5,306.25,61.5,306.25,67,310,67,310,75,302.5,75,302.5,80,315,80,315,86,315,86.16666,345,86.16666,345,88,120,88,120,86.5,120,85,75,85,75,80,52.625,80,52.625,77,51.25,77,5,77,5,66,353.75,66,353.75,63,347.5,63,347.5,59.08333,343,59.08333,343,56.25,334.75,56.25,334.75,55,332,55,332,52.75,329.5,52.75,329.5,54.83333,309,54.83333,309,60.91667,308.05,60.91667,308.05,59.5,300,59.5], +["Cet",5,0,5,2,30,2,30,9.91667,49.25,9.91667,49.25,0,49.25,-1.75,39.75,-1.75,39.75,-24.38333,25,-24.38333,25,-25.5,357.5,-25.5,357.5,-7,5,-7,5,0], +["Cha",115,-82.5,115,-75,135.5,-75,168.75,-75,205,-75,205,-82.5,115,-82.5], +["Cir",202.5,-64,218,-64,218,-55,225.75,-55,230,-55,230,-60,230,-61,227.5,-61,227.5,-63.58333,223.75,-63.58333,223.75,-67.5,221.25,-67.5,221.25,-70,205,-70,205,-65,202.5,-65,202.5,-64], +["Col",75,-43,75,-27.25,91.75,-27.25,91.75,-33,98.75,-33,98.75,-43,90,-43,75,-43], +["Com",178,14,178,29,180,29,180,34,185,34,185,32,198.75,32,198.75,28.5,202.5,28.5,202.5,15,192.5,15,192.5,14,178,14], +["Cra",267.5,-37,287.5,-37,287.5,-45.5,270,-45.5,267.5,-45.5,267.5,-37], +["CrB",227.75,26,227.75,33,231.5,33,231.5,40,236.25,40,245,40,245,27,242.5,27,242.5,26,240.5,26,227.75,26], +["Crv",192.5,-11,192.5,-22,188.75,-22,188.75,-24.5,177.5,-24.5,177.5,-11,192.5,-11], +["Crt",161.25,-6,172.75,-6,177.5,-6,177.5,-11,177.5,-24.5,162.5,-24.5,162.5,-19,161.25,-19,161.25,-11,161.25,-6], +["Cru",177.5,-55,192.5,-55,192.5,-64,177.5,-64,177.5,-55], +["Cyg",288.875,27.5,288.875,30,290.375,30,290.375,36.5,291,36.5,291,43.5,287.5,43.5,287.5,47.5,286.25,47.5,286.25,55.5,291.25,55.5,291.25,58,296.5,58,296.5,59.5,300,59.5,308.05,59.5,308.05,60.91667,309,60.91667,309,54.83333,329.5,54.83333,329.5,52.75,329.5,44,328.625,44,328.625,43.75,328.125,43.75,328.125,36,326,36,326,28,321.25,28,313.75,28,313.75,29,295,29,295,27.5,288.875,27.5], +["Del",308,2,304.5,2,304.5,8.5,302.125,8.5,302.125,15.75,303.75,15.75,303.75,20.5,308.5,20.5,308.5,19.5,315.75,19.5,315.75,11.83333,313.125,11.83333,313.125,6,312.5,6,312.5,2,308,2], +["Dor",57.5,-53.16667,57.5,-51,61.25,-51,61.25,-49,64,-49,67.5,-49,67.5,-54,75,-54,75,-57.5,82.5,-57.5,82.5,-61,90,-61,90,-64,98.75,-64,98.75,-70,68.75,-70,68.75,-67.5,68.75,-59,65,-59,65,-56.5,60,-56.5,60,-53.16667,57.5,-53.16667], +["Dra",137.5,73.5,137.5,82,160,82,160,80,172.5,80,172.5,77,195,77,195,70,210,70,210,66,235,66,235,70,248,70,248,75,262.5,75,262.5,80,270,80,270,86,315,86,315,80,302.5,80,302.5,75,310,75,310,67,306.25,67,306.25,61.5,300,61.5,300,59.5,296.5,59.5,296.5,58,291.25,58,291.25,55.5,286.25,55.5,286.25,47.5,273.5,47.5,273.5,50.5,255,50.5,255,51.5,236.25,51.5,236.25,53,228.75,53,228.75,55.5,216.25,55.5,216.25,63,202.5,63,202.5,64,180,64,180,66.5,170,66.5,170,73.5,137.5,73.5], +["Equ",312.5,2,312.5,6,313.125,6,313.125,11.83333,315.75,11.83333,316.75,11.83333,316.75,12.5,320,12.5,320,2,312.5,2], +["Eri",53.75,0,69.25,0,70,0,70,-4,76.25,-4,76.25,-11,73.75,-11,73.75,-14.5,72.5,-14.5,72.5,-27.25,70.5,-27.25,70.5,-30,68.75,-30,68.75,-37,64,-37,64,-40,58,-40,58,-44,51.25,-44,51.25,-46,45,-46,45,-49,40,-49,40,-51,36.25,-51,36.25,-54,32.5,-54,32.5,-58.5,20,-58.5,20,-53.5,23.75,-53.5,23.75,-51.5,27.5,-51.5,27.5,-48.16667,35,-48.16667,35,-40,45,-40,45,-39.58333,52.5,-39.58333,52.5,-36,56.25,-36,56.25,-24.38333,39.75,-24.38333,39.75,-1.75,49.25,-1.75,53.75,-1.75,53.75,0], +["For",25,-24.38333,39.75,-24.38333,56.25,-24.38333,56.25,-36,52.5,-36,52.5,-39.58333,45,-39.58333,45,-40,35,-40,25,-40,25,-25.5,25,-24.38333], +["Gem",94.625,12,94.625,17.5,93.25,17.5,93.25,21.5,88.25,21.5,88.25,22.83333,88.25,28,98,28,98,35.5,110.5,35.5,116.25,35.5,116.25,33.5,120,33.5,120,28,118.25,28,118.25,20,117.125,20,117.125,13.5,112.5,13.5,112.5,12.5,105,12.5,105,10,104,10,104,12,94.625,12], +["Gru",320,-37,345,-37,350,-37,350,-40,350,-57,330,-57,330,-50,320,-50,320,-45.5,320,-37], +["Her",244,4,241.25,4,241.25,16,238.75,16,238.75,22,240.5,22,240.5,26,242.5,26,242.5,27,245,27,245,40,236.25,40,236.25,51.5,255,51.5,255,50.5,273.5,50.5,273.5,47.5,272.625,47.5,272.625,30,275.5,30,275.5,26,283,26,283,25.5,283,21.08333,283,18.5,283,12,279.933,12,273.75,12,273.75,14.33333,258.75,14.33333,258.75,12.83333,251.25,12.83333,251.25,4,244,4], +["Hor",64,-40,64,-49,61.25,-49,61.25,-51,57.5,-51,57.5,-53.16667,52.5,-53.16667,52.5,-57.5,48,-57.5,48,-67.5,32.5,-67.5,32.5,-58.5,32.5,-54,36.25,-54,36.25,-51,40,-51,40,-49,45,-49,45,-46,51.25,-46,51.25,-44,58,-44,58,-40,64,-40], +["Hya",121.25,0,121.25,7,138.75,7,143.75,7,143.75,0,143.75,-11,161.25,-11,161.25,-19,162.5,-19,162.5,-24.5,177.5,-24.5,188.75,-24.5,188.75,-22,192.5,-22,213.75,-22,213.75,-24.5,223.75,-24.5,223.75,-29.5,188.75,-29.5,188.75,-33,183.75,-33,183.75,-35,165,-35,162.5,-35,162.5,-31.16667,158.75,-31.16667,158.75,-29.16667,153.75,-29.16667,153.75,-26.5,146.25,-26.5,146.25,-24,140.5,-24,136.25,-24,136.25,-19,128.75,-19,128.75,-17,125.5,-17,125.5,-11,121.25,-11,121.25,0], +["Hyi",68.75,-67.5,68.75,-70,68.75,-75,52.5,-75,52.5,-82.5,0,-82.5,0,-75,11.25,-75,11.25,-76,20,-76,20,-58.5,32.5,-58.5,32.5,-67.5,48,-67.5,68.75,-67.5], +["Ind",320,-75,320,-60,305,-60,305,-57,305,-45.5,320,-45.5,320,-50,330,-50,330,-57,330,-67.5,350,-67.5,350,-75,320,-75], +["Lac",328.125,36,328.125,43.75,328.625,43.75,328.625,44,329.5,44,329.5,52.75,332,52.75,332,55,334.75,55,334.75,56.25,343,56.25,343,52.5,343,34.5,342.25,34.5,342.25,35,330,35,330,36,328.125,36], +["Leo",161.25,0,161.25,7,143.75,7,138.75,7,138.75,33.5,148.25,33.5,148.25,28.5,157.5,28.5,157.5,23.5,161.25,23.5,161.25,25.5,165,25.5,165,29,178,29,178,14,178,11,172.75,11,172.75,0,172.75,-6,161.25,-6,161.25,0], +["LMi",138.75,33.5,138.75,39.75,143.75,39.75,143.75,42,152.5,42,152.5,40,161.75,40,161.75,34,165,34,165,29,165,25.5,161.25,25.5,161.25,23.5,157.5,23.5,157.5,28.5,148.25,28.5,148.25,33.5,138.75,33.5], +["Lep",72.5,-27.25,72.5,-14.5,73.75,-14.5,73.75,-11,76.25,-11,87.5,-11,91.75,-11,91.75,-27.25,75,-27.25,72.5,-27.25], +["Lib",226.25,0,226.25,-3.25,238.75,-3.25,238.75,-8,238.75,-20,235,-20,235,-29.5,223.75,-29.5,223.75,-24.5,213.75,-24.5,213.75,-22,213.75,-8,220,-8,220,0,226.25,0], +["Lup",212.5,-55,212.5,-42,223.75,-42,223.75,-29.5,235,-29.5,240,-29.5,240,-42,235,-42,235,-48,230,-48,230,-54,225.75,-54,225.75,-55,218,-55,212.5,-55], +["Lyn",110.5,35.5,110.5,44.5,102,44.5,102,50,97.5,50,97.5,54,91.5,54,91.5,56,91.5,62,105,62,105,60,119.5,60,126.25,60,126.25,47,137.5,47,137.5,42,143.75,42,143.75,39.75,138.75,39.75,138.75,33.5,120,33.5,116.25,33.5,116.25,35.5,110.5,35.5], +["Lyr",283,25.5,283,26,275.5,26,275.5,30,272.625,30,272.625,47.5,273.5,47.5,286.25,47.5,287.5,47.5,287.5,43.5,291,43.5,291,36.5,290.375,36.5,290.375,30,288.875,30,288.875,27.5,288.875,25.5,283,25.5], +["Men",115,-85,52.5,-85,52.5,-82.5,52.5,-75,68.75,-75,68.75,-70,98.75,-70,98.75,-75,115,-75,115,-82.5,115,-85], +["Mic",305,-28,320,-28,320,-37,320,-45.5,305,-45.5,305,-28], +["Mon",93.625,0,93.625,10,94.625,10,94.625,12,104,12,104,10,105,10,105,5.5,105.25,5.5,105.25,1.5,108,1.5,108,0,121.25,0,121.25,-11,110.5,-11,91.75,-11,87.5,-11,87.5,-4,93.625,-4,93.625,0], +["Mus",168.75,-64,177.5,-64,192.5,-64,202.5,-64,202.5,-65,205,-65,205,-70,205,-75,168.75,-75,168.75,-64], +["Nor",230,-60,230,-55,225.75,-55,225.75,-54,230,-54,230,-48,235,-48,235,-42,240,-42,246.312,-42,246.312,-45.5,246.312,-60,230,-60], +["Oct",0,-82.5,52.5,-82.5,52.5,-85,115,-85,115,-82.5,205,-82.5,270,-82.5,270,-75,320,-75,350,-75,0,-75,0,-82.5], +["Oph",244,0,244,4,251.25,4,251.25,12.83333,258.75,12.83333,258.75,14.33333,273.75,14.33333,273.75,12,279.933,12,279.933,6.25,273.75,6.25,273.75,4.5,276.375,4.5,276.375,3,273.75,3,273.75,0,267.5,0,267.5,-4,269.5,-4,269.5,-10,265,-10,265,-11.66667,263.75,-11.66667,263.75,-10,257.5,-10,257.5,-16,264,-16,264,-30,251.25,-30,251.25,-24.58333,244,-24.58333,244,-19.25,245.625,-19.25,245.625,-18.25,244,-18.25,244,-8,238.75,-8,238.75,-3.25,244,-3.25,244,0], +["Ori",69.25,0,69.25,15.5,74.5,15.5,74.5,16,80,16,80,15.5,84,15.5,84,12.5,86.5,12.5,86.5,18,85.5,18,85.5,22.83333,88.25,22.83333,88.25,21.5,93.25,21.5,93.25,17.5,94.625,17.5,94.625,12,94.625,10,93.625,10,93.625,0,93.625,-4,87.5,-4,87.5,-11,76.25,-11,76.25,-4,70,-4,70,0,69.25,0], +["Pav",270,-75,270,-67.5,262.5,-67.5,262.5,-57,270,-57,305,-57,305,-60,320,-60,320,-75,270,-75], +["Peg",320,2,320,12.5,316.75,12.5,316.75,11.83333,315.75,11.83333,315.75,19.5,318.75,19.5,318.75,23.5,321.25,23.5,321.25,28,326,28,326,36,328.125,36,330,36,330,35,342.25,35,342.25,34.5,343,34.5,352.5,34.5,352.5,32.08333,356.25,32.08333,356.25,31.33333,0,31.33333,0,28,1,28,1,22,2.125,22,2.125,21,2.125,12.5,0,12.5,0,10,357.5,10,357.5,7.5,341.25,7.5,341.25,2,330,2,330,1.75,325,1.75,325,2.75,322,2.75,322,2,320,2], +["Per",40.75,30.66667,40.75,34,38.5,34,38.5,36.75,37.75,36.75,37.75,50.5,30.625,50.5,30.625,47,25,47,25,50,20.5,50,20.5,54,25.5,54,25.5,57.5,28.625,57.5,28.625,58.5,36.5,58.5,36.5,57,46.5,57,47.5,57,47.5,55,50,55,50,52.5,70.375,52.5,70.375,36,67.5,36,67.5,30.66667,50.5,30.66667,40.75,30.66667], +["Phe",350,-40,25,-40,35,-40,35,-48.16667,27.5,-48.16667,27.5,-51.5,23.75,-51.5,23.75,-53.5,20,-53.5,20,-58.5,350,-58.5,350,-57,350,-40], +["Pic",90,-43,90,-50.75,90,-52.5,92.5,-52.5,92.5,-55,97.5,-55,97.5,-58,102.5,-58,102.5,-64,98.75,-64,90,-64,90,-61,82.5,-61,82.5,-57.5,75,-57.5,75,-54,67.5,-54,67.5,-49,67.5,-46.5,72.5,-46.5,72.5,-43,75,-43,90,-43], +["Psc",341.25,0,341.25,2,341.25,7.5,357.5,7.5,357.5,10,0,10,0,12.5,2.125,12.5,2.125,21,12.75,21,12.75,23.75,10.75,23.75,10.75,33,21.125,33,21.125,28,25,28,25,25,25,9.91667,30,9.91667,30,2,5,2,5,0,5,-7,357.5,-7,357.5,-4,341.25,-4,341.25,0], +["PsA",345,-25.5,345,-37,320,-37,320,-28,320,-25.5,328,-25.5,345,-25.5], +["Pup",110.5,-11,121.25,-11,125.5,-11,125.5,-17,125.5,-36.75,125.5,-43,120,-43,120,-50.75,90,-50.75,90,-43,98.75,-43,98.75,-33,110.5,-33,110.5,-11], +["Pyx",125.5,-17,128.75,-17,128.75,-19,136.25,-19,136.25,-24,140.5,-24,140.5,-36.75,125.5,-36.75,125.5,-17], +["Ret",48,-67.5,48,-57.5,52.5,-57.5,52.5,-53.16667,57.5,-53.16667,60,-53.16667,60,-56.5,65,-56.5,65,-59,68.75,-59,68.75,-67.5,48,-67.5], +["Sge",283,18.5,283,21.08333,288.75,21.08333,288.75,19.16667,297.5,19.16667,297.5,21.25,303.75,21.25,303.75,20.5,303.75,15.75,302.125,15.75,297.5,15.75,297.5,16.16667,285,16.16667,285,18.5,283,18.5], +["Sgr",283,-12.03333,300,-12.03333,300,-28,305,-28,305,-45.5,287.5,-45.5,287.5,-37,267.5,-37,267.5,-30,264,-30,264,-16,273.75,-16,283,-16,283,-12.03333], +["Sco",238.75,-8,244,-8,244,-18.25,245.625,-18.25,245.625,-19.25,244,-19.25,244,-24.58333,251.25,-24.58333,251.25,-30,264,-30,267.5,-30,267.5,-37,267.5,-45.5,246.312,-45.5,246.312,-42,240,-42,240,-29.5,235,-29.5,235,-20,238.75,-20,238.75,-8], +["Scl",345,-25.5,357.5,-25.5,25,-25.5,25,-40,350,-40,350,-37,345,-37,345,-25.5], +["Sct",273.75,-16,273.75,-4,278.75,-4,283,-4,283,-12.03333,283,-16,273.75,-16], +["Ser1",226.25,0,226.25,8,226.25,26,227.75,26,240.5,26,240.5,22,238.75,22,238.75,16,241.25,16,241.25,4,244,4,244,0,244,-3.25,238.75,-3.25,226.25,-3.25,226.25,0], +["Ser2",273.75,0,273.75,3,276.375,3,276.375,4.5,273.75,4.5,273.75,6.25,279.933,6.25,283,6.25,283,2,278.75,2,278.75,0,278.75,-4,273.75,-4,273.75,-16,264,-16,257.5,-16,257.5,-10,263.75,-10,263.75,-11.66667,265,-11.66667,265,-10,269.5,-10,269.5,-4,267.5,-4,267.5,0,273.75,0], +["Sex",143.75,0,143.75,7,161.25,7,161.25,0,161.25,-6,161.25,-11,143.75,-11,143.75,0], +["Tau",49.25,-1.75,49.25,0,49.25,9.91667,49.25,19,50.5,19,50.5,30.66667,67.5,30.66667,67.5,30,71.25,30,71.25,28.5,88.25,28.5,88.25,28,88.25,22.83333,85.5,22.83333,85.5,18,86.5,18,86.5,12.5,84,12.5,84,15.5,80,15.5,80,16,74.5,16,74.5,15.5,69.25,15.5,69.25,0,53.75,0,53.75,-1.75,49.25,-1.75], +["Tel",305,-57,270,-57,270,-45.5,287.5,-45.5,305,-45.5,305,-57], +["Tri",25,25,25,28,21.125,28,21.125,33,21.125,35,30,35,30,36.75,37.75,36.75,38.5,36.75,38.5,34,40.75,34,40.75,30.66667,36.25,30.66667,36.25,27.25,28.75,27.25,28.75,25,25,25], +["TrA",221.25,-70,221.25,-67.5,223.75,-67.5,223.75,-63.58333,227.5,-63.58333,227.5,-61,230,-61,230,-60,246.312,-60,246.312,-61,248.75,-61,248.75,-63.58333,251.25,-63.58333,251.25,-65,252.5,-65,252.5,-67.5,255,-67.5,255,-70,221.25,-70], +["Tuc",350,-75,350,-67.5,330,-67.5,330,-57,350,-57,350,-58.5,20,-58.5,20,-76,11.25,-76,11.25,-75,0,-75,350,-75], +["UMa",143.75,42,137.5,42,137.5,47,126.25,47,126.25,60,119.5,60,119.5,73.5,137.5,73.5,170,73.5,170,66.5,180,66.5,180,64,202.5,64,202.5,63,216.25,63,216.25,55.5,210.5,55.5,210.5,48.5,202.5,48.5,202.5,53,181.25,53,181.25,45,180,45,180,34,180,29,178,29,165,29,165,34,161.75,34,161.75,40,152.5,40,152.5,42,143.75,42], +["UMi",195,77,203.75,77,203.75,80,217.5,80,217.5,86.5,120,86.5,120,88,345,88,345,86.16666,315,86.16666,315,86,270,86,270,80,262.5,80,262.5,75,248,75,248,70,235,70,235,66,210,66,210,70,195,70,195,77], +["Vel",165,-56.5,132.5,-56.5,132.5,-54.5,126.75,-54.5,126.75,-53,122.5,-53,122.5,-50.75,120,-50.75,120,-43,125.5,-43,125.5,-36.75,140.5,-36.75,140.5,-39.75,165,-39.75,165,-56.5], +["Vir",172.75,0,172.75,11,178,11,178,14,192.5,14,192.5,15,202.5,15,202.5,8,226.25,8,226.25,0,220,0,220,-8,213.75,-8,213.75,-22,192.5,-22,192.5,-11,177.5,-11,177.5,-6,172.75,-6,172.75,0], +["Vol",98.75,-64,102.5,-64,135.5,-64,135.5,-75,115,-75,98.75,-75,98.75,-70,98.75,-64], +["Vul",283,21.08333,283,25.5,288.875,25.5,288.875,27.5,295,27.5,295,29,313.75,29,313.75,28,321.25,28,321.25,23.5,318.75,23.5,318.75,19.5,315.75,19.5,308.5,19.5,308.5,20.5,303.75,20.5,303.75,21.25,297.5,21.25,297.5,19.16667,288.75,19.16667,288.75,21.08333,283,21.08333] + ] +} diff --git a/html/allsky/virtualsky/extra/prettify.css b/html/allsky/virtualsky/extra/prettify.css new file mode 100755 index 000000000..021a8e126 --- /dev/null +++ b/html/allsky/virtualsky/extra/prettify.css @@ -0,0 +1,89 @@ +/* Pretty printing styles. Used with prettify.js. */ + +.str { color: #080; } +.kwd { color: #008; } +.com { color: #800; } +.typ { color: #606; } +.lit { color: #066; } +.pun { color: #660; } +.pln { color: #000; } +.tag { color: #008; } +.atn { color: #606; } +.atv { color: #080; } +.dec { color: #606; } +pre.prettyprint { padding: 2px; overflow: auto; } + +/* Specify class=linenums on a pre to get line numbering */ +ol.linenums { margin-top: 0; margin-bottom: 0 } /* IE indents via margin-left */ +li.L0, +li.L1, +li.L2, +li.L3, +li.L5, +li.L6, +li.L7, +li.L8 { list-style-type: none } +/* Alternate shading for lines */ +li.L1, +li.L3, +li.L5, +li.L7, +li.L9 { background: #eee } + +@media print { + .str { color: #060; } + .kwd { color: #006; font-weight: bold; } + .com { color: #600; font-style: italic; } + .typ { color: #404; font-weight: bold; } + .lit { color: #044; } + .pun { color: #440; } + .pln { color: #000; } + .tag { color: #006; font-weight: bold; } + .atn { color: #404; } + .atv { color: #060; } +} + + +/* Pretty printing styles. Used with prettify.js. */ +/* Vim sunburst theme by David Leibovic */ + +pre .str, code .str { color: #65B042; } /* string - green */ +pre .kwd, code .kwd { color: #E28964; } /* keyword - dark pink */ +pre .com, code .com { color: #AEAEAE; font-style: italic; } /* comment - gray */ +pre .typ, code .typ { color: #89bdff; } /* type - light blue */ +pre .lit, code .lit { color: #3387CC; } /* literal - blue */ +pre .pun, code .pun { color: #fff; } /* punctuation - white */ +pre .pln, code .pln { color: #fff; } /* plaintext - white */ +pre .tag, code .tag { color: #89bdff; } /* html/xml tag - light blue */ +pre .atn, code .atn { color: #bdb76b; } /* html/xml attribute name - khaki */ +pre .atv, code .atv { color: #65B042; } /* html/xml attribute value - green */ +pre .dec, code .dec { color: #3387CC; } /* decimal - blue */ + +pre.prettyprint, code.prettyprint { + background-color: #111111; +} + +pre.prettyprint { + margin: 1em auto; + padding: 1em; + white-space: pre-wrap; +} + +/* Specify class=linenums on a pre to get line numbering */ +ol.linenums { margin-top: 0; margin-bottom: 0; color: #AEAEAE; } /* IE indents via margin-left */ +li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8 { list-style-type: none } +/* Alternate shading for lines */ +li.L1,li.L3,li.L5,li.L7,li.L9 { } + +@media print { + pre .str, code .str { color: #060; } + pre .kwd, code .kwd { color: #006; font-weight: bold; } + pre .com, code .com { color: #600; font-style: italic; } + pre .typ, code .typ { color: #404; font-weight: bold; } + pre .lit, code .lit { color: #044; } + pre .pun, code .pun { color: #440; } + pre .pln, code .pln { color: #000; } + pre .tag, code .tag { color: #006; font-weight: bold; } + pre .atn, code .atn { color: #404; } + pre .atv, code .atv { color: #060; } +} \ No newline at end of file diff --git a/html/allsky/virtualsky/extra/prettify.js b/html/allsky/virtualsky/extra/prettify.js new file mode 100755 index 000000000..75893b997 --- /dev/null +++ b/html/allsky/virtualsky/extra/prettify.js @@ -0,0 +1,1508 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + + +/** + * @fileoverview + * some functions for browser-side pretty printing of code contained in html. + *

+ * + * For a fairly comprehensive set of languages see the + * README + * file that came with this source. At a minimum, the lexer should work on a + * number of languages including C and friends, Java, Python, Bash, SQL, HTML, + * XML, CSS, Javascript, and Makefiles. It works passably on Ruby, PHP and Awk + * and a subset of Perl, but, because of commenting conventions, doesn't work on + * Smalltalk, Lisp-like, or CAML-like languages without an explicit lang class. + *

+ * Usage:

    + *
  1. include this source file in an html page via + * {@code } + *
  2. define style rules. See the example page for examples. + *
  3. mark the {@code
    } and {@code } tags in your source with
    + *    {@code class=prettyprint.}
    + *    You can also use the (html deprecated) {@code } tag, but the pretty
    + *    printer needs to do more substantial DOM manipulations to support that, so
    + *    some css styles may not be preserved.
    + * </ol>
    + * That's it.  I wanted to keep the API as simple as possible, so there's no
    + * need to specify which language the code is in, but if you wish, you can add
    + * another class to the {@code <pre>} or {@code <code>} element to specify the
    + * language, as in {@code <pre class="prettyprint lang-java">}.  Any class that
    + * starts with "lang-" followed by a file extension, specifies the file type.
    + * See the "lang-*.js" files in this directory for code that implements
    + * per-language file handlers.
    + * <p>
    + * Change log:<br>
    + * cbeust, 2006/08/22
    + * <blockquote>
    + *   Java annotations (start with "@") are now captured as literals ("lit")
    + * </blockquote>
    + * @requires console
    + */
    +
    +// JSLint declarations
    +/*global console, document, navigator, setTimeout, window */
    +
    +/**
    + * Split {@code prettyPrint} into multiple timeouts so as not to interfere with
    + * UI events.
    + * If set to {@code false}, {@code prettyPrint()} is synchronous.
    + */
    +window['PR_SHOULD_USE_CONTINUATION'] = true;
    +
    +/** the number of characters between tab columns */
    +window['PR_TAB_WIDTH'] = 8;
    +
    +/** Walks the DOM returning a properly escaped version of innerHTML.
    +  * @param {Node} node
    +  * @param {Array.<string>} out output buffer that receives chunks of HTML.
    +  */
    +window['PR_normalizedHtml']
    +
    +/** Contains functions for creating and registering new language handlers.
    +  * @type {Object}
    +  */
    +  = window['PR']
    +
    +/** Pretty print a chunk of code.
    +  *
    +  * @param {string} sourceCodeHtml code as html
    +  * @return {string} code as html, but prettier
    +  */
    +  = window['prettyPrintOne']
    +/** Find all the {@code <pre>} and {@code <code>} tags in the DOM with
    +  * {@code class=prettyprint} and prettify them.
    +  * @param {Function?} opt_whenDone if specified, called when the last entry
    +  *     has been finished.
    +  */
    +  = window['prettyPrint'] = void 0;
    +
    +/** browser detection. @extern @returns false if not IE, otherwise the major version. */
    +window['_pr_isIE6'] = function () {
    +  var ieVersion = navigator && navigator.userAgent &&
    +      navigator.userAgent.match(/\bMSIE ([678])\./);
    +  ieVersion = ieVersion ? +ieVersion[1] : false;
    +  window['_pr_isIE6'] = function () { return ieVersion; };
    +  return ieVersion;
    +};
    +
    +
    +(function () {
    +  // Keyword lists for various languages.
    +  var FLOW_CONTROL_KEYWORDS =
    +      "break continue do else for if return while ";
    +  var C_KEYWORDS = FLOW_CONTROL_KEYWORDS + "auto case char const default " +
    +      "double enum extern float goto int long register short signed sizeof " +
    +      "static struct switch typedef union unsigned void volatile ";
    +  var COMMON_KEYWORDS = C_KEYWORDS + "catch class delete false import " +
    +      "new operator private protected public this throw true try typeof ";
    +  var CPP_KEYWORDS = COMMON_KEYWORDS + "alignof align_union asm axiom bool " +
    +      "concept concept_map const_cast constexpr decltype " +
    +      "dynamic_cast explicit export friend inline late_check " +
    +      "mutable namespace nullptr reinterpret_cast static_assert static_cast " +
    +      "template typeid typename using virtual wchar_t where ";
    +  var JAVA_KEYWORDS = COMMON_KEYWORDS +
    +      "abstract boolean byte extends final finally implements import " +
    +      "instanceof null native package strictfp super synchronized throws " +
    +      "transient ";
    +  var CSHARP_KEYWORDS = JAVA_KEYWORDS +
    +      "as base by checked decimal delegate descending event " +
    +      "fixed foreach from group implicit in interface internal into is lock " +
    +      "object out override orderby params partial readonly ref sbyte sealed " +
    +      "stackalloc string select uint ulong unchecked unsafe ushort var ";
    +  var JSCRIPT_KEYWORDS = COMMON_KEYWORDS +
    +      "debugger eval export function get null set undefined var with " +
    +      "Infinity NaN ";
    +  var PERL_KEYWORDS = "caller delete die do dump elsif eval exit foreach for " +
    +      "goto if import last local my next no our print package redo require " +
    +      "sub undef unless until use wantarray while BEGIN END ";
    +  var PYTHON_KEYWORDS = FLOW_CONTROL_KEYWORDS + "and as assert class def del " +
    +      "elif except exec finally from global import in is lambda " +
    +      "nonlocal not or pass print raise try with yield " +
    +      "False True None ";
    +  var RUBY_KEYWORDS = FLOW_CONTROL_KEYWORDS + "alias and begin case class def" +
    +      " defined elsif end ensure false in module next nil not or redo rescue " +
    +      "retry self super then true undef unless until when yield BEGIN END ";
    +  var SH_KEYWORDS = FLOW_CONTROL_KEYWORDS + "case done elif esac eval fi " +
    +      "function in local set then until ";
    +  var ALL_KEYWORDS = (
    +      CPP_KEYWORDS + CSHARP_KEYWORDS + JSCRIPT_KEYWORDS + PERL_KEYWORDS +
    +      PYTHON_KEYWORDS + RUBY_KEYWORDS + SH_KEYWORDS);
    +
    +  // token style names.  correspond to css classes
    +  /** token style for a string literal */
    +  var PR_STRING = 'str';
    +  /** token style for a keyword */
    +  var PR_KEYWORD = 'kwd';
    +  /** token style for a comment */
    +  var PR_COMMENT = 'com';
    +  /** token style for a type */
    +  var PR_TYPE = 'typ';
    +  /** token style for a literal value.  e.g. 1, null, true. */
    +  var PR_LITERAL = 'lit';
    +  /** token style for a punctuation string. */
    +  var PR_PUNCTUATION = 'pun';
    +  /** token style for a punctuation string. */
    +  var PR_PLAIN = 'pln';
    +
    +  /** token style for an sgml tag. */
    +  var PR_TAG = 'tag';
    +  /** token style for a markup declaration such as a DOCTYPE. */
    +  var PR_DECLARATION = 'dec';
    +  /** token style for embedded source. */
    +  var PR_SOURCE = 'src';
    +  /** token style for an sgml attribute name. */
    +  var PR_ATTRIB_NAME = 'atn';
    +  /** token style for an sgml attribute value. */
    +  var PR_ATTRIB_VALUE = 'atv';
    +
    +  /**
    +   * A class that indicates a section of markup that is not code, e.g. to allow
    +   * embedding of line numbers within code listings.
    +   */
    +  var PR_NOCODE = 'nocode';
    +
    +  /** A set of tokens that can precede a regular expression literal in
    +    * javascript.
    +    * http://www.mozilla.org/js/language/js20/rationale/syntax.html has the full
    +    * list, but I've removed ones that might be problematic when seen in
    +    * languages that don't support regular expression literals.
    +    *
    +    * <p>Specifically, I've removed any keywords that can't precede a regexp
    +    * literal in a syntactically legal javascript program, and I've removed the
    +    * "in" keyword since it's not a keyword in many languages, and might be used
    +    * as a count of inches.
    +    *
    +    * <p>The link a above does not accurately describe EcmaScript rules since
    +    * it fails to distinguish between (a=++/b/i) and (a++/b/i) but it works
    +    * very well in practice.
    +    *
    +    * @private
    +    */
    +  var REGEXP_PRECEDER_PATTERN = function () {
    +      var preceders = [
    +          "!", "!=", "!==", "#", "%", "%=", "&", "&&", "&&=",
    +          "&=", "(", "*", "*=", /* "+", */ "+=", ",", /* "-", */ "-=",
    +          "->", /*".", "..", "...", handled below */ "/", "/=", ":", "::", ";",
    +          "<", "<<", "<<=", "<=", "=", "==", "===", ">",
    +          ">=", ">>", ">>=", ">>>", ">>>=", "?", "@", "[",
    +          "^", "^=", "^^", "^^=", "{", "|", "|=", "||",
    +          "||=", "~" /* handles =~ and !~ */,
    +          "break", "case", "continue", "delete",
    +          "do", "else", "finally", "instanceof",
    +          "return", "throw", "try", "typeof"
    +          ];
    +      var pattern = '(?:^^|[+-]';
    +      for (var i = 0; i < preceders.length; ++i) {
    +        pattern += '|' + preceders[i].replace(/([^=<>:&a-z])/g, '\\$1');
    +      }
    +      pattern += ')\\s*';  // matches at end, and matches empty string
    +      return pattern;
    +      // CAVEAT: this does not properly handle the case where a regular
    +      // expression immediately follows another since a regular expression may
    +      // have flags for case-sensitivity and the like.  Having regexp tokens
    +      // adjacent is not valid in any language I'm aware of, so I'm punting.
    +      // TODO: maybe style special characters inside a regexp as punctuation.
    +    }();
    +
    +  // Define regexps here so that the interpreter doesn't have to create an
    +  // object each time the function containing them is called.
    +  // The language spec requires a new object created even if you don't access
    +  // the $1 members.
    +  var pr_amp = /&/g;
    +  var pr_lt = /</g;
    +  var pr_gt = />/g;
    +  var pr_quot = /\"/g;
    +  /** like textToHtml but escapes double quotes to be attribute safe. */
    +  function attribToHtml(str) {
    +    return str.replace(pr_amp, '&amp;')
    +        .replace(pr_lt, '&lt;')
    +        .replace(pr_gt, '&gt;')
    +        .replace(pr_quot, '&quot;');
    +  }
    +
    +  /** escapest html special characters to html. */
    +  function textToHtml(str) {
    +    return str.replace(pr_amp, '&amp;')
    +        .replace(pr_lt, '&lt;')
    +        .replace(pr_gt, '&gt;');
    +  }
    +
    +
    +  var pr_ltEnt = /&lt;/g;
    +  var pr_gtEnt = /&gt;/g;
    +  var pr_aposEnt = /&apos;/g;
    +  var pr_quotEnt = /&quot;/g;
    +  var pr_ampEnt = /&amp;/g;
    +  var pr_nbspEnt = /&nbsp;/g;
    +  /** unescapes html to plain text. */
    +  function htmlToText(html) {
    +    var pos = html.indexOf('&');
    +    if (pos < 0) { return html; }
    +    // Handle numeric entities specially.  We can't use functional substitution
    +    // since that doesn't work in older versions of Safari.
    +    // These should be rare since most browsers convert them to normal chars.
    +    for (--pos; (pos = html.indexOf('&#', pos + 1)) >= 0;) {
    +      var end = html.indexOf(';', pos);
    +      if (end >= 0) {
    +        var num = html.substring(pos + 3, end);
    +        var radix = 10;
    +        if (num && num.charAt(0) === 'x') {
    +          num = num.substring(1);
    +          radix = 16;
    +        }
    +        var codePoint = parseInt(num, radix);
    +        if (!isNaN(codePoint)) {
    +          html = (html.substring(0, pos) + String.fromCharCode(codePoint) +
    +                  html.substring(end + 1));
    +        }
    +      }
    +    }
    +
    +    return html.replace(pr_ltEnt, '<')
    +        .replace(pr_gtEnt, '>')
    +        .replace(pr_aposEnt, "'")
    +        .replace(pr_quotEnt, '"')
    +        .replace(pr_nbspEnt, ' ')
    +        .replace(pr_ampEnt, '&');
    +  }
    +
    +  /** is the given node's innerHTML normally unescaped? */
    +  function isRawContent(node) {
    +    return 'XMP' === node.tagName;
    +  }
    +
    +  var newlineRe = /[\r\n]/g;
    +  /**
    +   * Are newlines and adjacent spaces significant in the given node's innerHTML?
    +   */
    +  function isPreformatted(node, content) {
    +    // PRE means preformatted, and is a very common case, so don't create
    +    // unnecessary computed style objects.
    +    if ('PRE' === node.tagName) { return true; }
    +    if (!newlineRe.test(content)) { return true; }  // Don't care
    +    var whitespace = '';
    +    // For disconnected nodes, IE has no currentStyle.
    +    if (node.currentStyle) {
    +      whitespace = node.currentStyle.whiteSpace;
    +    } else if (window.getComputedStyle) {
    +      // Firefox makes a best guess if node is disconnected whereas Safari
    +      // returns the empty string.
    +      whitespace = window.getComputedStyle(node, null).whiteSpace;
    +    }
    +    return !whitespace || whitespace === 'pre';
    +  }
    +
    +  function normalizedHtml(node, out, opt_sortAttrs) {
    +    switch (node.nodeType) {
    +      case 1:  // an element
    +        var name = node.tagName.toLowerCase();
    +
    +        out.push('<', name);
    +        var attrs = node.attributes;
    +        var n = attrs.length;
    +        if (n) {
    +          if (opt_sortAttrs) {
    +            var sortedAttrs = [];
    +            for (var i = n; --i >= 0;) { sortedAttrs[i] = attrs[i]; }
    +            sortedAttrs.sort(function (a, b) {
    +                return (a.name < b.name) ? -1 : a.name === b.name ? 0 : 1;
    +              });
    +            attrs = sortedAttrs;
    +          }
    +          for (var i = 0; i < n; ++i) {
    +            var attr = attrs[i];
    +            if (!attr.specified) { continue; }
    +            out.push(' ', attr.name.toLowerCase(),
    +                     '="', attribToHtml(attr.value), '"');
    +          }
    +        }
    +        out.push('>');
    +        for (var child = node.firstChild; child; child = child.nextSibling) {
    +          normalizedHtml(child, out, opt_sortAttrs);
    +        }
    +        if (node.firstChild || !/^(?:br|link|img)$/.test(name)) {
    +          out.push('<\/', name, '>');
    +        }
    +        break;
    +      case 3: case 4: // text
    +        out.push(textToHtml(node.nodeValue));
    +        break;
    +    }
    +  }
    +
    +  /**
    +   * Given a group of {@link RegExp}s, returns a {@code RegExp} that globally
    +   * matches the union o the sets o strings matched d by the input RegExp.
    +   * Since it matches globally, if the input strings have a start-of-input
    +   * anchor (/^.../), it is ignored for the purposes of unioning.
    +   * @param {Array.<RegExp>} regexs non multiline, non-global regexs.
    +   * @return {RegExp} a global regex.
    +   */
    +  function combinePrefixPatterns(regexs) {
    +    var capturedGroupIndex = 0;
    +
    +    var needToFoldCase = false;
    +    var ignoreCase = false;
    +    for (var i = 0, n = regexs.length; i < n; ++i) {
    +      var regex = regexs[i];
    +      if (regex.ignoreCase) {
    +        ignoreCase = true;
    +      } else if (/[a-z]/i.test(regex.source.replace(
    +                     /\\u[0-9a-f]{4}|\\x[0-9a-f]{2}|\\[^ux]/gi, ''))) {
    +        needToFoldCase = true;
    +        ignoreCase = false;
    +        break;
    +      }
    +    }
    +
    +    function decodeEscape(charsetPart) {
    +      if (charsetPart.charAt(0) !== '\\') { return charsetPart.charCodeAt(0); }
    +      switch (charsetPart.charAt(1)) {
    +        case 'b': return 8;
    +        case 't': return 9;
    +        case 'n': return 0xa;
    +        case 'v': return 0xb;
    +        case 'f': return 0xc;
    +        case 'r': return 0xd;
    +        case 'u': case 'x':
    +          return parseInt(charsetPart.substring(2), 16)
    +              || charsetPart.charCodeAt(1);
    +        case '0': case '1': case '2': case '3': case '4':
    +        case '5': case '6': case '7':
    +          return parseInt(charsetPart.substring(1), 8);
    +        default: return charsetPart.charCodeAt(1);
    +      }
    +    }
    +
    +    function encodeEscape(charCode) {
    +      if (charCode < 0x20) {
    +        return (charCode < 0x10 ? '\\x0' : '\\x') + charCode.toString(16);
    +      }
    +      var ch = String.fromCharCode(charCode);
    +      if (ch === '\\' || ch === '-' || ch === '[' || ch === ']') {
    +        ch = '\\' + ch;
    +      }
    +      return ch;
    +    }
    +
    +    function caseFoldCharset(charSet) {
    +      var charsetParts = charSet.substring(1, charSet.length - 1).match(
    +          new RegExp(
    +              '\\\\u[0-9A-Fa-f]{4}'
    +              + '|\\\\x[0-9A-Fa-f]{2}'
    +              + '|\\\\[0-3][0-7]{0,2}'
    +              + '|\\\\[0-7]{1,2}'
    +              + '|\\\\[\\s\\S]'
    +              + '|-'
    +              + '|[^-\\\\]',
    +              'g'));
    +      var groups = [];
    +      var ranges = [];
    +      var inverse = charsetParts[0] === '^';
    +      for (var i = inverse ? 1 : 0, n = charsetParts.length; i < n; ++i) {
    +        var p = charsetParts[i];
    +        switch (p) {
    +          case '\\B': case '\\b':
    +          case '\\D': case '\\d':
    +          case '\\S': case '\\s':
    +          case '\\W': case '\\w':
    +            groups.push(p);
    +            continue;
    +        }
    +        var start = decodeEscape(p);
    +        var end;
    +        if (i + 2 < n && '-' === charsetParts[i + 1]) {
    +          end = decodeEscape(charsetParts[i + 2]);
    +          i += 2;
    +        } else {
    +          end = start;
    +        }
    +        ranges.push([start, end]);
    +        // If the range might intersect letters, then expand it.
    +        if (!(end < 65 || start > 122)) {
    +          if (!(end < 65 || start > 90)) {
    +            ranges.push([Math.max(65, start) | 32, Math.min(end, 90) | 32]);
    +          }
    +          if (!(end < 97 || start > 122)) {
    +            ranges.push([Math.max(97, start) & ~32, Math.min(end, 122) & ~32]);
    +          }
    +        }
    +      }
    +
    +      // [[1, 10], [3, 4], [8, 12], [14, 14], [16, 16], [17, 17]]
    +      // -> [[1, 12], [14, 14], [16, 17]]
    +      ranges.sort(function (a, b) { return (a[0] - b[0]) || (b[1]  - a[1]); });
    +      var consolidatedRanges = [];
    +      var lastRange = [NaN, NaN];
    +      for (var i = 0; i < ranges.length; ++i) {
    +        var range = ranges[i];
    +        if (range[0] <= lastRange[1] + 1) {
    +          lastRange[1] = Math.max(lastRange[1], range[1]);
    +        } else {
    +          consolidatedRanges.push(lastRange = range);
    +        }
    +      }
    +
    +      var out = ['['];
    +      if (inverse) { out.push('^'); }
    +      out.push.apply(out, groups);
    +      for (var i = 0; i < consolidatedRanges.length; ++i) {
    +        var range = consolidatedRanges[i];
    +        out.push(encodeEscape(range[0]));
    +        if (range[1] > range[0]) {
    +          if (range[1] + 1 > range[0]) { out.push('-'); }
    +          out.push(encodeEscape(range[1]));
    +        }
    +      }
    +      out.push(']');
    +      return out.join('');
    +    }
    +
    +    function allowAnywhereFoldCaseAndRenumberGroups(regex) {
    +      // Split into character sets, escape sequences, punctuation strings
    +      // like ('(', '(?:', ')', '^'), and runs of characters that do not
    +      // include any of the above.
    +      var parts = regex.source.match(
    +          new RegExp(
    +              '(?:'
    +              + '\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]'  // a character set
    +              + '|\\\\u[A-Fa-f0-9]{4}'  // a unicode escape
    +              + '|\\\\x[A-Fa-f0-9]{2}'  // a hex escape
    +              + '|\\\\[0-9]+'  // a back-reference or octal escape
    +              + '|\\\\[^ux0-9]'  // other escape sequence
    +              + '|\\(\\?[:!=]'  // start of a non-capturing group
    +              + '|[\\(\\)\\^]'  // start/emd of a group, or line start
    +              + '|[^\\x5B\\x5C\\(\\)\\^]+'  // run of other characters
    +              + ')',
    +              'g'));
    +      var n = parts.length;
    +
    +      // Maps captured group numbers to the number they will occupy in
    +      // the output or to -1 if that has not been determined, or to
    +      // undefined if they need not be capturing in the output.
    +      var capturedGroups = [];
    +
    +      // Walk over and identify back references to build the capturedGroups
    +      // mapping.
    +      for (var i = 0, groupIndex = 0; i < n; ++i) {
    +        var p = parts[i];
    +        if (p === '(') {
    +          // groups are 1-indexed, so max group index is count of '('
    +          ++groupIndex;
    +        } else if ('\\' === p.charAt(0)) {
    +          var decimalValue = +p.substring(1);
    +          if (decimalValue && decimalValue <= groupIndex) {
    +            capturedGroups[decimalValue] = -1;
    +          }
    +        }
    +      }
    +
    +      // Renumber groups and reduce capturing groups to non-capturing groups
    +      // where possible.
    +      for (var i = 1; i < capturedGroups.length; ++i) {
    +        if (-1 === capturedGroups[i]) {
    +          capturedGroups[i] = ++capturedGroupIndex;
    +        }
    +      }
    +      for (var i = 0, groupIndex = 0; i < n; ++i) {
    +        var p = parts[i];
    +        if (p === '(') {
    +          ++groupIndex;
    +          if (capturedGroups[groupIndex] === undefined) {
    +            parts[i] = '(?:';
    +          }
    +        } else if ('\\' === p.charAt(0)) {
    +          var decimalValue = +p.substring(1);
    +          if (decimalValue && decimalValue <= groupIndex) {
    +            parts[i] = '\\' + capturedGroups[groupIndex];
    +          }
    +        }
    +      }
    +
    +      // Remove any prefix anchors so that the output will match anywhere.
    +      // ^^ really does mean an anchored match though.
    +      for (var i = 0, groupIndex = 0; i < n; ++i) {
    +        if ('^' === parts[i] && '^' !== parts[i + 1]) { parts[i] = ''; }
    +      }
    +
    +      // Expand letters to groupts to handle mixing of case-sensitive and
    +      // case-insensitive patterns if necessary.
    +      if (regex.ignoreCase && needToFoldCase) {
    +        for (var i = 0; i < n; ++i) {
    +          var p = parts[i];
    +          var ch0 = p.charAt(0);
    +          if (p.length >= 2 && ch0 === '[') {
    +            parts[i] = caseFoldCharset(p);
    +          } else if (ch0 !== '\\') {
    +            // TODO: handle letters in numeric escapes.
    +            parts[i] = p.replace(
    +                /[a-zA-Z]/g,
    +                function (ch) {
    +                  var cc = ch.charCodeAt(0);
    +                  return '[' + String.fromCharCode(cc & ~32, cc | 32) + ']';
    +                });
    +          }
    +        }
    +      }
    +
    +      return parts.join('');
    +    }
    +
    +    var rewritten = [];
    +    for (var i = 0, n = regexs.length; i < n; ++i) {
    +      var regex = regexs[i];
    +      if (regex.global || regex.multiline) { throw new Error('' + regex); }
    +      rewritten.push(
    +          '(?:' + allowAnywhereFoldCaseAndRenumberGroups(regex) + ')');
    +    }
    +
    +    return new RegExp(rewritten.join('|'), ignoreCase ? 'gi' : 'g');
    +  }
    +
    +  var PR_innerHtmlWorks = null;
    +  function getInnerHtml(node) {
    +    // inner html is hopelessly broken in Safari 2.0.4 when the content is
    +    // an html description of well formed XML and the containing tag is a PRE
    +    // tag, so we detect that case and emulate innerHTML.
    +    if (null === PR_innerHtmlWorks) {
    +      var testNode = document.createElement('PRE');
    +      testNode.appendChild(
    +          document.createTextNode('<!DOCTYPE foo PUBLIC "foo bar">\n<foo />'));
    +      PR_innerHtmlWorks = !/</.test(testNode.innerHTML);
    +    }
    +
    +    if (PR_innerHtmlWorks) {
    +      var content = node.innerHTML;
    +      // XMP tags contain unescaped entities so require special handling.
    +      if (isRawContent(node)) {
    +        content = textToHtml(content);
    +      } else if (!isPreformatted(node, content)) {
    +        content = content.replace(/(<br\s*\/?>)[\r\n]+/g, '$1')
    +            .replace(/(?:[\r\n]+[ \t]*)+/g, ' ');
    +      }
    +      return content;
    +    }
    +
    +    var out = [];
    +    for (var child = node.firstChild; child; child = child.nextSibling) {
    +      normalizedHtml(child, out);
    +    }
    +    return out.join('');
    +  }
    +
    +  /** returns a function that expand tabs to spaces.  This function can be fed
    +    * successive chunks of text, and will maintain its own internal state to
    +    * keep track of how tabs are expanded.
    +    * @return {function (string) : string} a function that takes
    +    *   plain text and return the text with tabs expanded.
    +    * @private
    +    */
    +  function makeTabExpander(tabWidth) {
    +    var SPACES = '                ';
    +    var charInLine = 0;
    +
    +    return function (plainText) {
    +      // walk over each character looking for tabs and newlines.
    +      // On tabs, expand them.  On newlines, reset charInLine.
    +      // Otherwise increment charInLine
    +      var out = null;
    +      var pos = 0;
    +      for (var i = 0, n = plainText.length; i < n; ++i) {
    +        var ch = plainText.charAt(i);
    +
    +        switch (ch) {
    +          case '\t':
    +            if (!out) { out = []; }
    +            out.push(plainText.substring(pos, i));
    +            // calculate how much space we need in front of this part
    +            // nSpaces is the amount of padding -- the number of spaces needed
    +            // to move us to the next column, where columns occur at factors of
    +            // tabWidth.
    +            var nSpaces = tabWidth - (charInLine % tabWidth);
    +            charInLine += nSpaces;
    +            for (; nSpaces >= 0; nSpaces -= SPACES.length) {
    +              out.push(SPACES.substring(0, nSpaces));
    +            }
    +            pos = i + 1;
    +            break;
    +          case '\n':
    +            charInLine = 0;
    +            break;
    +          default:
    +            ++charInLine;
    +        }
    +      }
    +      if (!out) { return plainText; }
    +      out.push(plainText.substring(pos));
    +      return out.join('');
    +    };
    +  }
    +
    +  var pr_chunkPattern = new RegExp(
    +      '[^<]+'  // A run of characters other than '<'
    +      + '|<\!--[\\s\\S]*?--\>'  // an HTML comment
    +      + '|<!\\[CDATA\\[[\\s\\S]*?\\]\\]>'  // a CDATA section
    +      // a probable tag that should not be highlighted
    +      + '|<\/?[a-zA-Z](?:[^>\"\']|\'[^\']*\'|\"[^\"]*\")*>'
    +      + '|<',  // A '<' that does not begin a larger chunk
    +      'g');
    +  var pr_commentPrefix = /^<\!--/;
    +  var pr_cdataPrefix = /^<!\[CDATA\[/;
    +  var pr_brPrefix = /^<br\b/i;
    +  var pr_tagNameRe = /^<(\/?)([a-zA-Z][a-zA-Z0-9]*)/;
    +
    +  /** split markup into chunks of html tags (style null) and
    +    * plain text (style {@link #PR_PLAIN}), converting tags which are
    +    * significant for tokenization (<br>) into their textual equivalent.
    +    *
    +    * @param {string} s html where whitespace is considered significant.
    +    * @return {Object} source code and extracted tags.
    +    * @private
    +    */
    +  function extractTags(s) {
    +    // since the pattern has the 'g' modifier and defines no capturing groups,
    +    // this will return a list of all chunks which we then classify and wrap as
    +    // PR_Tokens
    +    var matches = s.match(pr_chunkPattern);
    +    var sourceBuf = [];
    +    var sourceBufLen = 0;
    +    var extractedTags = [];
    +    if (matches) {
    +      for (var i = 0, n = matches.length; i < n; ++i) {
    +        var match = matches[i];
    +        if (match.length > 1 && match.charAt(0) === '<') {
    +          if (pr_commentPrefix.test(match)) { continue; }
    +          if (pr_cdataPrefix.test(match)) {
    +            // strip CDATA prefix and suffix.  Don't unescape since it's CDATA
    +            sourceBuf.push(match.substring(9, match.length - 3));
    +            sourceBufLen += match.length - 12;
    +          } else if (pr_brPrefix.test(match)) {
    +            // <br> tags are lexically significant so convert them to text.
    +            // This is undone later.
    +            sourceBuf.push('\n');
    +            ++sourceBufLen;
    +          } else {
    +            if (match.indexOf(PR_NOCODE) >= 0 && isNoCodeTag(match)) {
    +              // A <span class="nocode"> will start a section that should be
    +              // ignored.  Continue walking the list until we see a matching end
    +              // tag.
    +              var name = match.match(pr_tagNameRe)[2];
    +              var depth = 1;
    +              var j;
    +              end_tag_loop:
    +              for (j = i + 1; j < n; ++j) {
    +                var name2 = matches[j].match(pr_tagNameRe);
    +                if (name2 && name2[2] === name) {
    +                  if (name2[1] === '/') {
    +                    if (--depth === 0) { break end_tag_loop; }
    +                  } else {
    +                    ++depth;
    +                  }
    +                }
    +              }
    +              if (j < n) {
    +                extractedTags.push(
    +                    sourceBufLen, matches.slice(i, j + 1).join(''));
    +                i = j;
    +              } else {  // Ignore unclosed sections.
    +                extractedTags.push(sourceBufLen, match);
    +              }
    +            } else {
    +              extractedTags.push(sourceBufLen, match);
    +            }
    +          }
    +        } else {
    +          var literalText = htmlToText(match);
    +          sourceBuf.push(literalText);
    +          sourceBufLen += literalText.length;
    +        }
    +      }
    +    }
    +    return { source: sourceBuf.join(''), tags: extractedTags };
    +  }
    +
    +  /** True if the given tag contains a class attribute with the nocode class. */
    +  function isNoCodeTag(tag) {
    +    return !!tag
    +        // First canonicalize the representation of attributes
    +        .replace(/\s(\w+)\s*=\s*(?:\"([^\"]*)\"|'([^\']*)'|(\S+))/g,
    +                 ' $1="$2$3$4"')
    +        // Then look for the attribute we want.
    +        .match(/[cC][lL][aA][sS][sS]=\"[^\"]*\bnocode\b/);
    +  }
    +
    +  /**
    +   * Apply the given language handler to sourceCode and add the resulting
    +   * decorations to out.
    +   * @param {number} basePos the index of sourceCode within the chunk of source
    +   *    whose decorations are already present on out.
    +   */
    +  function appendDecorations(basePos, sourceCode, langHandler, out) {
    +    if (!sourceCode) { return; }
    +    var job = {
    +      source: sourceCode,
    +      basePos: basePos
    +    };
    +    langHandler(job);
    +    out.push.apply(out, job.decorations);
    +  }
    +
    +  /** Given triples of [style, pattern, context] returns a lexing function,
    +    * The lexing function interprets the patterns to find token boundaries and
    +    * returns a decoration list of the form
    +    * [index_0, style_0, index_1, style_1, ..., index_n, style_n]
    +    * where index_n is an index into the sourceCode, and style_n is a style
    +    * constant like PR_PLAIN.  index_n-1 <= index_n, and style_n-1 applies to
    +    * all characters in sourceCode[index_n-1:index_n].
    +    *
    +    * The stylePatterns is a list whose elements have the form
    +    * [style : string, pattern : RegExp, DEPRECATED, shortcut : string].
    +    *
    +    * Style is a style constant like PR_PLAIN, or can be a string of the
    +    * form 'lang-FOO', where FOO is a language extension describing the
    +    * language of the portion of the token in $1 after pattern executes.
    +    * E.g., if style is 'lang-lisp', and group 1 contains the text
    +    * '(hello (world))', then that portion of the token will be passed to the
    +    * registered lisp handler for formatting.
    +    * The text before and after group 1 will be restyled using this decorator
    +    * so decorators should take care that this doesn't result in infinite
    +    * recursion.  For example, the HTML lexer rule for SCRIPT elements looks
    +    * something like ['lang-js', /<[s]cript>(.+?)<\/script>/].  This may match
    +    * '<script>foo()<\/script>', which would cause the current decorator to
    +    * be called with '<script>' which would not match the same rule since
    +    * group 1 must not be empty, so it would be instead styled as PR_TAG by
    +    * the generic tag rule.  The handler registered for the 'js' extension would
    +    * then be called with 'foo()', and finally, the current decorator would
    +    * be called with '<\/script>' which would not match the original rule and
    +    * so the generic tag rule would identify it as a tag.
    +    *
    +    * Pattern must only match prefixes, and if it matches a prefix, then that
    +    * match is considered a token with the same style.
    +    *
    +    * Context is applied to the last non-whitespace, non-comment token
    +    * recognized.
    +    *
    +    * Shortcut is an optional string of characters, any of which, if the first
    +    * character, gurantee that this pattern and only this pattern matches.
    +    *
    +    * @param {Array} shortcutStylePatterns patterns that always start with
    +    *   a known character.  Must have a shortcut string.
    +    * @param {Array} fallthroughStylePatterns patterns that will be tried in
    +    *   order if the shortcut ones fail.  May have shortcuts.
    +    *
    +    * @return {function (Object)} a
    +    *   function that takes source code and returns a list of decorations.
    +    */
    +  function createSimpleLexer(shortcutStylePatterns, fallthroughStylePatterns) {
    +    var shortcuts = {};
    +    var tokenizer;
    +    (function () {
    +      var allPatterns = shortcutStylePatterns.concat(fallthroughStylePatterns);
    +      var allRegexs = [];
    +      var regexKeys = {};
    +      for (var i = 0, n = allPatterns.length; i < n; ++i) {
    +        var patternParts = allPatterns[i];
    +        var shortcutChars = patternParts[3];
    +        if (shortcutChars) {
    +          for (var c = shortcutChars.length; --c >= 0;) {
    +            shortcuts[shortcutChars.charAt(c)] = patternParts;
    +          }
    +        }
    +        var regex = patternParts[1];
    +        var k = '' + regex;
    +        if (!regexKeys.hasOwnProperty(k)) {
    +          allRegexs.push(regex);
    +          regexKeys[k] = null;
    +        }
    +      }
    +      allRegexs.push(/[\0-\uffff]/);
    +      tokenizer = combinePrefixPatterns(allRegexs);
    +    })();
    +
    +    var nPatterns = fallthroughStylePatterns.length;
    +    var notWs = /\S/;
    +
    +    /**
    +     * Lexes job.source and produces an output array job.decorations of style
    +     * classes preceded by the position at which they start in job.source in
    +     * order.
    +     *
    +     * @param {Object} job an object like {@code
    +     *    source: {string} sourceText plain text,
    +     *    basePos: {int} position of job.source in the larger chunk of
    +     *        sourceCode.
    +     * }
    +     */
    +    var decorate = function (job) {
    +      var sourceCode = job.source, basePos = job.basePos;
    +      /** Even entries are positions in source in ascending order.  Odd enties
    +        * are style markers (e.g., PR_COMMENT) that run from that position until
    +        * the end.
    +        * @type {Array.<number|string>}
    +        */
    +      var decorations = [basePos, PR_PLAIN];
    +      var pos = 0;  // index into sourceCode
    +      var tokens = sourceCode.match(tokenizer) || [];
    +      var styleCache = {};
    +
    +      for (var ti = 0, nTokens = tokens.length; ti < nTokens; ++ti) {
    +        var token = tokens[ti];
    +        var style = styleCache[token];
    +        var match = void 0;
    +
    +        var isEmbedded;
    +        if (typeof style === 'string') {
    +          isEmbedded = false;
    +        } else {
    +          var patternParts = shortcuts[token.charAt(0)];
    +          if (patternParts) {
    +            match = token.match(patternParts[1]);
    +            style = patternParts[0];
    +          } else {
    +            for (var i = 0; i < nPatterns; ++i) {
    +              patternParts = fallthroughStylePatterns[i];
    +              match = token.match(patternParts[1]);
    +              if (match) {
    +                style = patternParts[0];
    +                break;
    +              }
    +            }
    +
    +            if (!match) {  // make sure that we make progress
    +              style = PR_PLAIN;
    +            }
    +          }
    +
    +          isEmbedded = style.length >= 5 && 'lang-' === style.substring(0, 5);
    +          if (isEmbedded && !(match && typeof match[1] === 'string')) {
    +            isEmbedded = false;
    +            style = PR_SOURCE;
    +          }
    +
    +          if (!isEmbedded) { styleCache[token] = style; }
    +        }
    +
    +        var tokenStart = pos;
    +        pos += token.length;
    +
    +        if (!isEmbedded) {
    +          decorations.push(basePos + tokenStart, style);
    +        } else {  // Treat group 1 as an embedded block of source code.
    +          var embeddedSource = match[1];
    +          var embeddedSourceStart = token.indexOf(embeddedSource);
    +          var embeddedSourceEnd = embeddedSourceStart + embeddedSource.length;
    +          if (match[2]) {
    +            // If embeddedSource can be blank, then it would match at the
    +            // beginning which would cause us to infinitely recurse on the
    +            // entire token, so we catch the right context in match[2].
    +            embeddedSourceEnd = token.length - match[2].length;
    +            embeddedSourceStart = embeddedSourceEnd - embeddedSource.length;
    +          }
    +          var lang = style.substring(5);
    +          // Decorate the left of the embedded source
    +          appendDecorations(
    +              basePos + tokenStart,
    +              token.substring(0, embeddedSourceStart),
    +              decorate, decorations);
    +          // Decorate the embedded source
    +          appendDecorations(
    +              basePos + tokenStart + embeddedSourceStart,
    +              embeddedSource,
    +              langHandlerForExtension(lang, embeddedSource),
    +              decorations);
    +          // Decorate the right of the embedded section
    +          appendDecorations(
    +              basePos + tokenStart + embeddedSourceEnd,
    +              token.substring(embeddedSourceEnd),
    +              decorate, decorations);
    +        }
    +      }
    +      job.decorations = decorations;
    +    };
    +    return decorate;
    +  }
    +
    +  /** returns a function that produces a list of decorations from source text.
    +    *
    +    * This code treats ", ', and ` as string delimiters, and \ as a string
    +    * escape.  It does not recognize perl's qq() style strings.
    +    * It has no special handling for double delimiter escapes as in basic, or
    +    * the tripled delimiters used in python, but should work on those regardless
    +    * although in those cases a single string literal may be broken up into
    +    * multiple adjacent string literals.
    +    *
    +    * It recognizes C, C++, and shell style comments.
    +    *
    +    * @param {Object} options a set of optional parameters.
    +    * @return {function (Object)} a function that examines the source code
    +    *     in the input job and builds the decoration list.
    +    */
    +  function sourceDecorator(options) {
    +    var shortcutStylePatterns = [], fallthroughStylePatterns = [];
    +    if (options['tripleQuotedStrings']) {
    +      // '''multi-line-string''', 'single-line-string', and double-quoted
    +      shortcutStylePatterns.push(
    +          [PR_STRING,  /^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,
    +           null, '\'"']);
    +    } else if (options['multiLineStrings']) {
    +      // 'multi-line-string', "multi-line-string"
    +      shortcutStylePatterns.push(
    +          [PR_STRING,  /^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,
    +           null, '\'"`']);
    +    } else {
    +      // 'single-line-string', "single-line-string"
    +      shortcutStylePatterns.push(
    +          [PR_STRING,
    +           /^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,
    +           null, '"\'']);
    +    }
    +    if (options['verbatimStrings']) {
    +      // verbatim-string-literal production from the C# grammar.  See issue 93.
    +      fallthroughStylePatterns.push(
    +          [PR_STRING, /^@\"(?:[^\"]|\"\")*(?:\"|$)/, null]);
    +    }
    +    if (options['hashComments']) {
    +      if (options['cStyleComments']) {
    +        // Stop C preprocessor declarations at an unclosed open comment
    +        shortcutStylePatterns.push(
    +            [PR_COMMENT, /^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,
    +             null, '#']);
    +        fallthroughStylePatterns.push(
    +            [PR_STRING,
    +             /^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,
    +             null]);
    +      } else {
    +        shortcutStylePatterns.push([PR_COMMENT, /^#[^\r\n]*/, null, '#']);
    +      }
    +    }
    +    if (options['cStyleComments']) {
    +      fallthroughStylePatterns.push([PR_COMMENT, /^\/\/[^\r\n]*/, null]);
    +      fallthroughStylePatterns.push(
    +          [PR_COMMENT, /^\/\*[\s\S]*?(?:\*\/|$)/, null]);
    +    }
    +    if (options['regexLiterals']) {
    +      var REGEX_LITERAL = (
    +          // A regular expression literal starts with a slash that is
    +          // not followed by * or / so that it is not confused with
    +          // comments.
    +          '/(?=[^/*])'
    +          // and then contains any number of raw characters,
    +          + '(?:[^/\\x5B\\x5C]'
    +          // escape sequences (\x5C),
    +          +    '|\\x5C[\\s\\S]'
    +          // or non-nesting character sets (\x5B\x5D);
    +          +    '|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+'
    +          // finally closed by a /.
    +          + '/');
    +      fallthroughStylePatterns.push(
    +          ['lang-regex',
    +           new RegExp('^' + REGEXP_PRECEDER_PATTERN + '(' + REGEX_LITERAL + ')')
    +           ]);
    +    }
    +
    +    var keywords = options['keywords'].replace(/^\s+|\s+$/g, '');
    +    if (keywords.length) {
    +      fallthroughStylePatterns.push(
    +          [PR_KEYWORD,
    +           new RegExp('^(?:' + keywords.replace(/\s+/g, '|') + ')\\b'), null]);
    +    }
    +
    +    shortcutStylePatterns.push([PR_PLAIN,       /^\s+/, null, ' \r\n\t\xA0']);
    +    fallthroughStylePatterns.push(
    +        // TODO(mikesamuel): recognize non-latin letters and numerals in idents
    +        [PR_LITERAL,     /^@[a-z_$][a-z_$@0-9]*/i, null],
    +        [PR_TYPE,        /^@?[A-Z]+[a-z][A-Za-z_$@0-9]*/, null],
    +        [PR_PLAIN,       /^[a-z_$][a-z_$@0-9]*/i, null],
    +        [PR_LITERAL,
    +         new RegExp(
    +             '^(?:'
    +             // A hex number
    +             + '0x[a-f0-9]+'
    +             // or an octal or decimal number,
    +             + '|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)'
    +             // possibly in scientific notation
    +             + '(?:e[+\\-]?\\d+)?'
    +             + ')'
    +             // with an optional modifier like UL for unsigned long
    +             + '[a-z]*', 'i'),
    +         null, '0123456789'],
    +        [PR_PUNCTUATION, /^.[^\s\w\.$@\'\"\`\/\#]*/, null]);
    +
    +    return createSimpleLexer(shortcutStylePatterns, fallthroughStylePatterns);
    +  }
    +
    +  var decorateSource = sourceDecorator({
    +        'keywords': ALL_KEYWORDS,
    +        'hashComments': true,
    +        'cStyleComments': true,
    +        'multiLineStrings': true,
    +        'regexLiterals': true
    +      });
    +
    +  /** Breaks {@code job.source} around style boundaries in
    +    * {@code job.decorations} while re-interleaving {@code job.extractedTags},
    +    * and leaves the result in {@code job.prettyPrintedHtml}.
    +    * @param {Object} job like {
    +    *    source: {string} source as plain text,
    +    *    extractedTags: {Array.<number|string>} extractedTags chunks of raw
    +    *                   html preceded by their position in {@code job.source}
    +    *                   in order
    +    *    decorations: {Array.<number|string} an array of style classes preceded
    +    *                 by the position at which they start in job.source in order
    +    * }
    +    * @private
    +    */
    +  function recombineTagsAndDecorations(job) {
    +    var sourceText = job.source;
    +    var extractedTags = job.extractedTags;
    +    var decorations = job.decorations;
    +
    +    var html = [];
    +    // index past the last char in sourceText written to html
    +    var outputIdx = 0;
    +
    +    var openDecoration = null;
    +    var currentDecoration = null;
    +    var tagPos = 0;  // index into extractedTags
    +    var decPos = 0;  // index into decorations
    +    var tabExpander = makeTabExpander(window['PR_TAB_WIDTH']);
    +
    +    var adjacentSpaceRe = /([\r\n ]) /g;
    +    var startOrSpaceRe = /(^| ) /gm;
    +    var newlineRe = /\r\n?|\n/g;
    +    var trailingSpaceRe = /[ \r\n]$/;
    +    var lastWasSpace = true;  // the last text chunk emitted ended with a space.
    +
    +    // See bug 71 and http://stackoverflow.com/questions/136443/why-doesnt-ie7-
    +    var isIE678 = window['_pr_isIE6']();
    +    var lineBreakHtml = (
    +        isIE678
    +        ? (job.sourceNode.tagName === 'PRE'
    +           // Use line feeds instead of <br>s so that copying and pasting works
    +           // on IE.
    +           // Doing this on other browsers breaks lots of stuff since \r\n is
    +           // treated as two newlines on Firefox.
    +           ? (isIE678 === 6 ? '&#160;\r\n' :
    +              isIE678 === 7 ? '&#160;<br>\r' : '&#160;\r')
    +           // IE collapses multiple adjacent <br>s into 1 line break.
    +           // Prefix every newline with '&#160;' to prevent such behavior.
    +           // &nbsp; is the same as &#160; but works in XML as well as HTML.
    +           : '&#160;<br />')
    +        : '<br />');
    +
    +    // Look for a class like linenums or linenums:<n> where <n> is the 1-indexed
    +    // number of the first line.
    +    var numberLines = job.sourceNode.className.match(/\blinenums\b(?::(\d+))?/);
    +    var lineBreaker;
    +    if (numberLines) {
    +      var lineBreaks = [];
    +      for (var i = 0; i < 10; ++i) {
    +        lineBreaks[i] = lineBreakHtml + '</li><li class="L' + i + '">';
    +      }
    +      var lineNum = numberLines[1] && numberLines[1].length 
    +          ? numberLines[1] - 1 : 0;  // Lines are 1-indexed
    +      html.push('<ol class="linenums"><li class="L', (lineNum) % 10, '"');
    +      if (lineNum) {
    +        html.push(' value="', lineNum + 1, '"');
    +      }
    +      html.push('>');
    +      lineBreaker = function () {
    +        var lb = lineBreaks[++lineNum % 10];
    +        // If a decoration is open, we need to close it before closing a list-item
    +        // and reopen it on the other side of the list item.
    +        return openDecoration
    +            ? ('</span>' + lb + '<span class="' + openDecoration + '">') : lb;
    +      };
    +    } else {
    +      lineBreaker = lineBreakHtml;
    +    }
    +
    +    // A helper function that is responsible for opening sections of decoration
    +    // and outputing properly escaped chunks of source
    +    function emitTextUpTo(sourceIdx) {
    +      if (sourceIdx > outputIdx) {
    +        if (openDecoration && openDecoration !== currentDecoration) {
    +          // Close the current decoration
    +          html.push('</span>');
    +          openDecoration = null;
    +        }
    +        if (!openDecoration && currentDecoration) {
    +          openDecoration = currentDecoration;
    +          html.push('<span class="', openDecoration, '">');
    +        }
    +        // This interacts badly with some wikis which introduces paragraph tags
    +        // into pre blocks for some strange reason.
    +        // It's necessary for IE though which seems to lose the preformattedness
    +        // of <pre> tags when their innerHTML is assigned.
    +        // http://stud3.tuwien.ac.at/~e0226430/innerHtmlQuirk.html
    +        // and it serves to undo the conversion of <br>s to newlines done in
    +        // chunkify.
    +        var htmlChunk = textToHtml(
    +            tabExpander(sourceText.substring(outputIdx, sourceIdx)))
    +            .replace(lastWasSpace
    +                     ? startOrSpaceRe
    +                     : adjacentSpaceRe, '$1&#160;');
    +        // Keep track of whether we need to escape space at the beginning of the
    +        // next chunk.
    +        lastWasSpace = trailingSpaceRe.test(htmlChunk);
    +        html.push(htmlChunk.replace(newlineRe, lineBreaker));
    +        outputIdx = sourceIdx;
    +      }
    +    }
    +
    +    while (true) {
    +      // Determine if we're going to consume a tag this time around.  Otherwise
    +      // we consume a decoration or exit.
    +      var outputTag;
    +      if (tagPos < extractedTags.length) {
    +        if (decPos < decorations.length) {
    +          // Pick one giving preference to extractedTags since we shouldn't open
    +          // a new style that we're going to have to immediately close in order
    +          // to output a tag.
    +          outputTag = extractedTags[tagPos] <= decorations[decPos];
    +        } else {
    +          outputTag = true;
    +        }
    +      } else {
    +        outputTag = false;
    +      }
    +      // Consume either a decoration or a tag or exit.
    +      if (outputTag) {
    +        emitTextUpTo(extractedTags[tagPos]);
    +        if (openDecoration) {
    +          // Close the current decoration
    +          html.push('</span>');
    +          openDecoration = null;
    +        }
    +        html.push(extractedTags[tagPos + 1]);
    +        tagPos += 2;
    +      } else if (decPos < decorations.length) {
    +        emitTextUpTo(decorations[decPos]);
    +        currentDecoration = decorations[decPos + 1];
    +        decPos += 2;
    +      } else {
    +        break;
    +      }
    +    }
    +    emitTextUpTo(sourceText.length);
    +    if (openDecoration) {
    +      html.push('</span>');
    +    }
    +    if (numberLines) { html.push('</li></ol>'); }
    +    job.prettyPrintedHtml = html.join('');
    +  }
    +
    +  /** Maps language-specific file extensions to handlers. */
    +  var langHandlerRegistry = {};
    +  /** Register a language handler for the given file extensions.
    +    * @param {function (Object)} handler a function from source code to a list
    +    *      of decorations.  Takes a single argument job which describes the
    +    *      state of the computation.   The single parameter has the form
    +    *      {@code {
    +    *        source: {string} as plain text.
    +    *        decorations: {Array.<number|string>} an array of style classes
    +    *                     preceded by the position at which they start in
    +    *                     job.source in order.
    +    *                     The language handler should assigned this field.
    +    *        basePos: {int} the position of source in the larger source chunk.
    +    *                 All positions in the output decorations array are relative
    +    *                 to the larger source chunk.
    +    *      } }
    +    * @param {Array.<string>} fileExtensions
    +    */
    +  function registerLangHandler(handler, fileExtensions) {
    +    for (var i = fileExtensions.length; --i >= 0;) {
    +      var ext = fileExtensions[i];
    +      if (!langHandlerRegistry.hasOwnProperty(ext)) {
    +        langHandlerRegistry[ext] = handler;
    +      } else if ('console' in window) {
    +        console['warn']('cannot override language handler %s', ext);
    +      }
    +    }
    +  }
    +  function langHandlerForExtension(extension, source) {
    +    if (!(extension && langHandlerRegistry.hasOwnProperty(extension))) {
    +      // Treat it as markup if the first non whitespace character is a < and
    +      // the last non-whitespace character is a >.
    +      extension = /^\s*</.test(source)
    +          ? 'default-markup'
    +          : 'default-code';
    +    }
    +    return langHandlerRegistry[extension];
    +  }
    +  registerLangHandler(decorateSource, ['default-code']);
    +  registerLangHandler(
    +      createSimpleLexer(
    +          [],
    +          [
    +           [PR_PLAIN,       /^[^<?]+/],
    +           [PR_DECLARATION, /^<!\w[^>]*(?:>|$)/],
    +           [PR_COMMENT,     /^<\!--[\s\S]*?(?:-\->|$)/],
    +           // Unescaped content in an unknown language
    +           ['lang-',        /^<\?([\s\S]+?)(?:\?>|$)/],
    +           ['lang-',        /^<%([\s\S]+?)(?:%>|$)/],
    +           [PR_PUNCTUATION, /^(?:<[%?]|[%?]>)/],
    +           ['lang-',        /^<xmp\b[^>]*>([\s\S]+?)<\/xmp\b[^>]*>/i],
    +           // Unescaped content in javascript.  (Or possibly vbscript).
    +           ['lang-js',      /^<script\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],
    +           // Contains unescaped stylesheet content
    +           ['lang-css',     /^<style\b[^>]*>([\s\S]*?)(<\/style\b[^>]*>)/i],
    +           ['lang-in.tag',  /^(<\/?[a-z][^<>]*>)/i]
    +          ]),
    +      ['default-markup', 'htm', 'html', 'mxml', 'xhtml', 'xml', 'xsl']);
    +  registerLangHandler(
    +      createSimpleLexer(
    +          [
    +           [PR_PLAIN,        /^[\s]+/, null, ' \t\r\n'],
    +           [PR_ATTRIB_VALUE, /^(?:\"[^\"]*\"?|\'[^\']*\'?)/, null, '\"\'']
    +           ],
    +          [
    +           [PR_TAG,          /^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],
    +           [PR_ATTRIB_NAME,  /^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],
    +           ['lang-uq.val',   /^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],
    +           [PR_PUNCTUATION,  /^[=<>\/]+/],
    +           ['lang-js',       /^on\w+\s*=\s*\"([^\"]+)\"/i],
    +           ['lang-js',       /^on\w+\s*=\s*\'([^\']+)\'/i],
    +           ['lang-js',       /^on\w+\s*=\s*([^\"\'>\s]+)/i],
    +           ['lang-css',      /^style\s*=\s*\"([^\"]+)\"/i],
    +           ['lang-css',      /^style\s*=\s*\'([^\']+)\'/i],
    +           ['lang-css',      /^style\s*=\s*([^\"\'>\s]+)/i]
    +           ]),
    +      ['in.tag']);
    +  registerLangHandler(
    +      createSimpleLexer([], [[PR_ATTRIB_VALUE, /^[\s\S]+/]]), ['uq.val']);
    +  registerLangHandler(sourceDecorator({
    +          'keywords': CPP_KEYWORDS,
    +          'hashComments': true,
    +          'cStyleComments': true
    +        }), ['c', 'cc', 'cpp', 'cxx', 'cyc', 'm']);
    +  registerLangHandler(sourceDecorator({
    +          'keywords': 'null true false'
    +        }), ['json']);
    +  registerLangHandler(sourceDecorator({
    +          'keywords': CSHARP_KEYWORDS,
    +          'hashComments': true,
    +          'cStyleComments': true,
    +          'verbatimStrings': true
    +        }), ['cs']);
    +  registerLangHandler(sourceDecorator({
    +          'keywords': JAVA_KEYWORDS,
    +          'cStyleComments': true
    +        }), ['java']);
    +  registerLangHandler(sourceDecorator({
    +          'keywords': SH_KEYWORDS,
    +          'hashComments': true,
    +          'multiLineStrings': true
    +        }), ['bsh', 'csh', 'sh']);
    +  registerLangHandler(sourceDecorator({
    +          'keywords': PYTHON_KEYWORDS,
    +          'hashComments': true,
    +          'multiLineStrings': true,
    +          'tripleQuotedStrings': true
    +        }), ['cv', 'py']);
    +  registerLangHandler(sourceDecorator({
    +          'keywords': PERL_KEYWORDS,
    +          'hashComments': true,
    +          'multiLineStrings': true,
    +          'regexLiterals': true
    +        }), ['perl', 'pl', 'pm']);
    +  registerLangHandler(sourceDecorator({
    +          'keywords': RUBY_KEYWORDS,
    +          'hashComments': true,
    +          'multiLineStrings': true,
    +          'regexLiterals': true
    +        }), ['rb']);
    +  registerLangHandler(sourceDecorator({
    +          'keywords': JSCRIPT_KEYWORDS,
    +          'cStyleComments': true,
    +          'regexLiterals': true
    +        }), ['js']);
    +  registerLangHandler(
    +      createSimpleLexer([], [[PR_STRING, /^[\s\S]+/]]), ['regex']);
    +
    +  function applyDecorator(job) {
    +    var sourceCodeHtml = job.sourceCodeHtml;
    +    var opt_langExtension = job.langExtension;
    +
    +    // Prepopulate output in case processing fails with an exception.
    +    job.prettyPrintedHtml = sourceCodeHtml;
    +
    +    try {
    +      // Extract tags, and convert the source code to plain text.
    +      var sourceAndExtractedTags = extractTags(sourceCodeHtml);
    +      /** Plain text. @type {string} */
    +      var source = sourceAndExtractedTags.source;
    +      job.source = source;
    +      job.basePos = 0;
    +
    +      /** Even entries are positions in source in ascending order.  Odd entries
    +        * are tags that were extracted at that position.
    +        * @type {Array.<number|string>}
    +        */
    +      job.extractedTags = sourceAndExtractedTags.tags;
    +
    +      // Apply the appropriate language handler
    +      langHandlerForExtension(opt_langExtension, source)(job);
    +      // Integrate the decorations and tags back into the source code to produce
    +      // a decorated html string which is left in job.prettyPrintedHtml.
    +      recombineTagsAndDecorations(job);
    +    } catch (e) {
    +      if ('console' in window) {
    +        console['log'](e && e['stack'] ? e['stack'] : e);
    +      }
    +    }
    +  }
    +
    +  function prettyPrintOne(sourceCodeHtml, opt_langExtension) {
    +    var job = {
    +      sourceCodeHtml: sourceCodeHtml,
    +      langExtension: opt_langExtension
    +    };
    +    applyDecorator(job);
    +    return job.prettyPrintedHtml;
    +  }
    +
    +  function prettyPrint(opt_whenDone) {
    +    function byTagName(tn) { return document.getElementsByTagName(tn); }
    +    // fetch a list of nodes to rewrite
    +    var codeSegments = [byTagName('pre'), byTagName('code'), byTagName('xmp')];
    +    var elements = [];
    +    for (var i = 0; i < codeSegments.length; ++i) {
    +      for (var j = 0, n = codeSegments[i].length; j < n; ++j) {
    +        elements.push(codeSegments[i][j]);
    +      }
    +    }
    +    codeSegments = null;
    +
    +    var clock = Date;
    +    if (!clock['now']) {
    +      clock = { 'now': function () { return (new Date).getTime(); } };
    +    }
    +
    +    // The loop is broken into a series of continuations to make sure that we
    +    // don't make the browser unresponsive when rewriting a large page.
    +    var k = 0;
    +    var prettyPrintingJob;
    +
    +    function doWork() {
    +      var endTime = (window['PR_SHOULD_USE_CONTINUATION'] ?
    +                     clock.now() + 250 /* ms */ :
    +                     Infinity);
    +      for (; k < elements.length && clock.now() < endTime; k++) {
    +        var cs = elements[k];
    +        if (cs.className && cs.className.indexOf('prettyprint') >= 0) {
    +          // If the classes includes a language extensions, use it.
    +          // Language extensions can be specified like
    +          //     <pre class="prettyprint lang-cpp">
    +          // the language extension "cpp" is used to find a language handler as
    +          // passed to PR_registerLangHandler.
    +          var langExtension = cs.className.match(/\blang-(\w+)\b/);
    +          if (langExtension) { langExtension = langExtension[1]; }
    +
    +          // make sure this is not nested in an already prettified element
    +          var nested = false;
    +          for (var p = cs.parentNode; p; p = p.parentNode) {
    +            if ((p.tagName === 'pre' || p.tagName === 'code' ||
    +                 p.tagName === 'xmp') &&
    +                p.className && p.className.indexOf('prettyprint') >= 0) {
    +              nested = true;
    +              break;
    +            }
    +          }
    +          if (!nested) {
    +            // fetch the content as a snippet of properly escaped HTML.
    +            // Firefox adds newlines at the end.
    +            var content = getInnerHtml(cs);
    +            content = content.replace(/(?:\r\n?|\n)$/, '');
    +
    +            // do the pretty printing
    +            prettyPrintingJob = {
    +              sourceCodeHtml: content,
    +              langExtension: langExtension,
    +              sourceNode: cs
    +            };
    +            applyDecorator(prettyPrintingJob);
    +            replaceWithPrettyPrintedHtml();
    +          }
    +        }
    +      }
    +      if (k < elements.length) {
    +        // finish up in a continuation
    +        setTimeout(doWork, 250);
    +      } else if (opt_whenDone) {
    +        opt_whenDone();
    +      }
    +    }
    +
    +    function replaceWithPrettyPrintedHtml() {
    +      var newContent = prettyPrintingJob.prettyPrintedHtml;
    +      if (!newContent) { return; }
    +      var cs = prettyPrintingJob.sourceNode;
    +
    +      // push the prettified html back into the tag.
    +      if (!isRawContent(cs)) {
    +        // just replace the old html with the new
    +        cs.innerHTML = newContent;
    +      } else {
    +        // we need to change the tag to a <pre> since <xmp>s do not allow
    +        // embedded tags such as the span tags used to attach styles to
    +        // sections of source code.
    +        var pre = document.createElement('PRE');
    +        for (var i = 0; i < cs.attributes.length; ++i) {
    +          var a = cs.attributes[i];
    +          if (a.specified) {
    +            var aname = a.name.toLowerCase();
    +            if (aname === 'class') {
    +              pre.className = a.value;  // For IE 6
    +            } else {
    +              pre.setAttribute(a.name, a.value);
    +            }
    +          }
    +        }
    +        pre.innerHTML = newContent;
    +
    +        // remove the old
    +        cs.parentNode.replaceChild(pre, cs);
    +        cs = pre;
    +      }
    +    }
    +
    +    doWork();
    +  }
    +
    +  window['PR_normalizedHtml'] = normalizedHtml;
    +  window['prettyPrintOne'] = prettyPrintOne;
    +  window['prettyPrint'] = prettyPrint;
    +  window['PR'] = {
    +        'combinePrefixPatterns': combinePrefixPatterns,
    +        'createSimpleLexer': createSimpleLexer,
    +        'registerLangHandler': registerLangHandler,
    +        'sourceDecorator': sourceDecorator,
    +        'PR_ATTRIB_NAME': PR_ATTRIB_NAME,
    +        'PR_ATTRIB_VALUE': PR_ATTRIB_VALUE,
    +        'PR_COMMENT': PR_COMMENT,
    +        'PR_DECLARATION': PR_DECLARATION,
    +        'PR_KEYWORD': PR_KEYWORD,
    +        'PR_LITERAL': PR_LITERAL,
    +        'PR_NOCODE': PR_NOCODE,
    +        'PR_PLAIN': PR_PLAIN,
    +        'PR_PUNCTUATION': PR_PUNCTUATION,
    +        'PR_SOURCE': PR_SOURCE,
    +        'PR_STRING': PR_STRING,
    +        'PR_TAG': PR_TAG,
    +        'PR_TYPE': PR_TYPE
    +      };
    +})();
    diff --git a/html/allsky/virtualsky/extra/qunit-1.12.0.css b/html/allsky/virtualsky/extra/qunit-1.12.0.css
    new file mode 100755
    index 000000000..7ba3f9a30
    --- /dev/null
    +++ b/html/allsky/virtualsky/extra/qunit-1.12.0.css
    @@ -0,0 +1,244 @@
    +/**
    + * QUnit v1.12.0 - A JavaScript Unit Testing Framework
    + *
    + * http://qunitjs.com
    + *
    + * Copyright 2012 jQuery Foundation and other contributors
    + * Released under the MIT license.
    + * http://jquery.org/license
    + */
    +
    +/** Font Family and Sizes */
    +
    +#qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult {
    +	font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif;
    +}
    +
    +#qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult, #qunit-tests li { font-size: small; }
    +#qunit-tests { font-size: smaller; }
    +
    +
    +/** Resets */
    +
    +#qunit-tests, #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult, #qunit-modulefilter {
    +	margin: 0;
    +	padding: 0;
    +}
    +
    +
    +/** Header */
    +
    +#qunit-header {
    +	padding: 0.5em 0 0.5em 1em;
    +
    +	color: #8699a4;
    +	background-color: #0d3349;
    +
    +	font-size: 1.5em;
    +	line-height: 1em;
    +	font-weight: normal;
    +
    +	border-radius: 5px 5px 0 0;
    +	-moz-border-radius: 5px 5px 0 0;
    +	-webkit-border-top-right-radius: 5px;
    +	-webkit-border-top-left-radius: 5px;
    +}
    +
    +#qunit-header a {
    +	text-decoration: none;
    +	color: #c2ccd1;
    +}
    +
    +#qunit-header a:hover,
    +#qunit-header a:focus {
    +	color: #fff;
    +}
    +
    +#qunit-testrunner-toolbar label {
    +	display: inline-block;
    +	padding: 0 .5em 0 .1em;
    +}
    +
    +#qunit-banner {
    +	height: 5px;
    +}
    +
    +#qunit-testrunner-toolbar {
    +	padding: 0.5em 0 0.5em 2em;
    +	color: #5E740B;
    +	background-color: #eee;
    +	overflow: hidden;
    +}
    +
    +#qunit-userAgent {
    +	padding: 0.5em 0 0.5em 2.5em;
    +	background-color: #2b81af;
    +	color: #fff;
    +	text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px;
    +}
    +
    +#qunit-modulefilter-container {
    +	float: right;
    +}
    +
    +/** Tests: Pass/Fail */
    +
    +#qunit-tests {
    +	list-style-position: inside;
    +}
    +
    +#qunit-tests li {
    +	padding: 0.4em 0.5em 0.4em 2.5em;
    +	border-bottom: 1px solid #fff;
    +	list-style-position: inside;
    +}
    +
    +#qunit-tests.hidepass li.pass, #qunit-tests.hidepass li.running  {
    +	display: none;
    +}
    +
    +#qunit-tests li strong {
    +	cursor: pointer;
    +}
    +
    +#qunit-tests li a {
    +	padding: 0.5em;
    +	color: #c2ccd1;
    +	text-decoration: none;
    +}
    +#qunit-tests li a:hover,
    +#qunit-tests li a:focus {
    +	color: #000;
    +}
    +
    +#qunit-tests li .runtime {
    +	float: right;
    +	font-size: smaller;
    +}
    +
    +.qunit-assert-list {
    +	margin-top: 0.5em;
    +	padding: 0.5em;
    +
    +	background-color: #fff;
    +
    +	border-radius: 5px;
    +	-moz-border-radius: 5px;
    +	-webkit-border-radius: 5px;
    +}
    +
    +.qunit-collapsed {
    +	display: none;
    +}
    +
    +#qunit-tests table {
    +	border-collapse: collapse;
    +	margin-top: .2em;
    +}
    +
    +#qunit-tests th {
    +	text-align: right;
    +	vertical-align: top;
    +	padding: 0 .5em 0 0;
    +}
    +
    +#qunit-tests td {
    +	vertical-align: top;
    +}
    +
    +#qunit-tests pre {
    +	margin: 0;
    +	white-space: pre-wrap;
    +	word-wrap: break-word;
    +}
    +
    +#qunit-tests del {
    +	background-color: #e0f2be;
    +	color: #374e0c;
    +	text-decoration: none;
    +}
    +
    +#qunit-tests ins {
    +	background-color: #ffcaca;
    +	color: #500;
    +	text-decoration: none;
    +}
    +
    +/*** Test Counts */
    +
    +#qunit-tests b.counts                       { color: black; }
    +#qunit-tests b.passed                       { color: #5E740B; }
    +#qunit-tests b.failed                       { color: #710909; }
    +
    +#qunit-tests li li {
    +	padding: 5px;
    +	background-color: #fff;
    +	border-bottom: none;
    +	list-style-position: inside;
    +}
    +
    +/*** Passing Styles */
    +
    +#qunit-tests li li.pass {
    +	color: #3c510c;
    +	background-color: #fff;
    +	border-left: 10px solid #C6E746;
    +}
    +
    +#qunit-tests .pass                          { color: #528CE0; background-color: #D2E0E6; }
    +#qunit-tests .pass .test-name               { color: #366097; }
    +
    +#qunit-tests .pass .test-actual,
    +#qunit-tests .pass .test-expected           { color: #999999; }
    +
    +#qunit-banner.qunit-pass                    { background-color: #C6E746; }
    +
    +/*** Failing Styles */
    +
    +#qunit-tests li li.fail {
    +	color: #710909;
    +	background-color: #fff;
    +	border-left: 10px solid #EE5757;
    +	white-space: pre;
    +}
    +
    +#qunit-tests > li:last-child {
    +	border-radius: 0 0 5px 5px;
    +	-moz-border-radius: 0 0 5px 5px;
    +	-webkit-border-bottom-right-radius: 5px;
    +	-webkit-border-bottom-left-radius: 5px;
    +}
    +
    +#qunit-tests .fail                          { color: #000000; background-color: #EE5757; }
    +#qunit-tests .fail .test-name,
    +#qunit-tests .fail .module-name             { color: #000000; }
    +
    +#qunit-tests .fail .test-actual             { color: #EE5757; }
    +#qunit-tests .fail .test-expected           { color: green;   }
    +
    +#qunit-banner.qunit-fail                    { background-color: #EE5757; }
    +
    +
    +/** Result */
    +
    +#qunit-testresult {
    +	padding: 0.5em 0.5em 0.5em 2.5em;
    +
    +	color: #2b81af;
    +	background-color: #D2E0E6;
    +
    +	border-bottom: 1px solid white;
    +}
    +#qunit-testresult .module-name {
    +	font-weight: bold;
    +}
    +
    +/** Fixture */
    +
    +#qunit-fixture {
    +	position: absolute;
    +	top: -10000px;
    +	left: -10000px;
    +	width: 1000px;
    +	height: 1000px;
    +}
    diff --git a/html/allsky/virtualsky/extra/qunit-1.12.0.js b/html/allsky/virtualsky/extra/qunit-1.12.0.js
    new file mode 100755
    index 000000000..84c73907d
    --- /dev/null
    +++ b/html/allsky/virtualsky/extra/qunit-1.12.0.js
    @@ -0,0 +1,2212 @@
    +/**
    + * QUnit v1.12.0 - A JavaScript Unit Testing Framework
    + *
    + * http://qunitjs.com
    + *
    + * Copyright 2013 jQuery Foundation and other contributors
    + * Released under the MIT license.
    + * https://jquery.org/license/
    + */
    +
    +(function( window ) {
    +
    +var QUnit,
    +	assert,
    +	config,
    +	onErrorFnPrev,
    +	testId = 0,
    +	fileName = (sourceFromStacktrace( 0 ) || "" ).replace(/(:\d+)+\)?/, "").replace(/.+\//, ""),
    +	toString = Object.prototype.toString,
    +	hasOwn = Object.prototype.hasOwnProperty,
    +	// Keep a local reference to Date (GH-283)
    +	Date = window.Date,
    +	setTimeout = window.setTimeout,
    +	defined = {
    +		setTimeout: typeof window.setTimeout !== "undefined",
    +		sessionStorage: (function() {
    +			var x = "qunit-test-string";
    +			try {
    +				sessionStorage.setItem( x, x );
    +				sessionStorage.removeItem( x );
    +				return true;
    +			} catch( e ) {
    +				return false;
    +			}
    +		}())
    +	},
    +	/**
    +	 * Provides a normalized error string, correcting an issue
    +	 * with IE 7 (and prior) where Error.prototype.toString is
    +	 * not properly implemented
    +	 *
    +	 * Based on http://es5.github.com/#x15.11.4.4
    +	 *
    +	 * @param {String|Error} error
    +	 * @return {String} error message
    +	 */
    +	errorString = function( error ) {
    +		var name, message,
    +			errorString = error.toString();
    +		if ( errorString.substring( 0, 7 ) === "[object" ) {
    +			name = error.name ? error.name.toString() : "Error";
    +			message = error.message ? error.message.toString() : "";
    +			if ( name && message ) {
    +				return name + ": " + message;
    +			} else if ( name ) {
    +				return name;
    +			} else if ( message ) {
    +				return message;
    +			} else {
    +				return "Error";
    +			}
    +		} else {
    +			return errorString;
    +		}
    +	},
    +	/**
    +	 * Makes a clone of an object using only Array or Object as base,
    +	 * and copies over the own enumerable properties.
    +	 *
    +	 * @param {Object} obj
    +	 * @return {Object} New object with only the own properties (recursively).
    +	 */
    +	objectValues = function( obj ) {
    +		// Grunt 0.3.x uses an older version of jshint that still has jshint/jshint#392.
    +		/*jshint newcap: false */
    +		var key, val,
    +			vals = QUnit.is( "array", obj ) ? [] : {};
    +		for ( key in obj ) {
    +			if ( hasOwn.call( obj, key ) ) {
    +				val = obj[key];
    +				vals[key] = val === Object(val) ? objectValues(val) : val;
    +			}
    +		}
    +		return vals;
    +	};
    +
    +function Test( settings ) {
    +	extend( this, settings );
    +	this.assertions = [];
    +	this.testNumber = ++Test.count;
    +}
    +
    +Test.count = 0;
    +
    +Test.prototype = {
    +	init: function() {
    +		var a, b, li,
    +			tests = id( "qunit-tests" );
    +
    +		if ( tests ) {
    +			b = document.createElement( "strong" );
    +			b.innerHTML = this.nameHtml;
    +
    +			// `a` initialized at top of scope
    +			a = document.createElement( "a" );
    +			a.innerHTML = "Rerun";
    +			a.href = QUnit.url({ testNumber: this.testNumber });
    +
    +			li = document.createElement( "li" );
    +			li.appendChild( b );
    +			li.appendChild( a );
    +			li.className = "running";
    +			li.id = this.id = "qunit-test-output" + testId++;
    +
    +			tests.appendChild( li );
    +		}
    +	},
    +	setup: function() {
    +		if (
    +			// Emit moduleStart when we're switching from one module to another
    +			this.module !== config.previousModule ||
    +				// They could be equal (both undefined) but if the previousModule property doesn't
    +				// yet exist it means this is the first test in a suite that isn't wrapped in a
    +				// module, in which case we'll just emit a moduleStart event for 'undefined'.
    +				// Without this, reporters can get testStart before moduleStart  which is a problem.
    +				!hasOwn.call( config, "previousModule" )
    +		) {
    +			if ( hasOwn.call( config, "previousModule" ) ) {
    +				runLoggingCallbacks( "moduleDone", QUnit, {
    +					name: config.previousModule,
    +					failed: config.moduleStats.bad,
    +					passed: config.moduleStats.all - config.moduleStats.bad,
    +					total: config.moduleStats.all
    +				});
    +			}
    +			config.previousModule = this.module;
    +			config.moduleStats = { all: 0, bad: 0 };
    +			runLoggingCallbacks( "moduleStart", QUnit, {
    +				name: this.module
    +			});
    +		}
    +
    +		config.current = this;
    +
    +		this.testEnvironment = extend({
    +			setup: function() {},
    +			teardown: function() {}
    +		}, this.moduleTestEnvironment );
    +
    +		this.started = +new Date();
    +		runLoggingCallbacks( "testStart", QUnit, {
    +			name: this.testName,
    +			module: this.module
    +		});
    +
    +		/*jshint camelcase:false */
    +
    +
    +		/**
    +		 * Expose the current test environment.
    +		 *
    +		 * @deprecated since 1.12.0: Use QUnit.config.current.testEnvironment instead.
    +		 */
    +		QUnit.current_testEnvironment = this.testEnvironment;
    +
    +		/*jshint camelcase:true */
    +
    +		if ( !config.pollution ) {
    +			saveGlobal();
    +		}
    +		if ( config.notrycatch ) {
    +			this.testEnvironment.setup.call( this.testEnvironment, QUnit.assert );
    +			return;
    +		}
    +		try {
    +			this.testEnvironment.setup.call( this.testEnvironment, QUnit.assert );
    +		} catch( e ) {
    +			QUnit.pushFailure( "Setup failed on " + this.testName + ": " + ( e.message || e ), extractStacktrace( e, 1 ) );
    +		}
    +	},
    +	run: function() {
    +		config.current = this;
    +
    +		var running = id( "qunit-testresult" );
    +
    +		if ( running ) {
    +			running.innerHTML = "Running: <br/>" + this.nameHtml;
    +		}
    +
    +		if ( this.async ) {
    +			QUnit.stop();
    +		}
    +
    +		this.callbackStarted = +new Date();
    +
    +		if ( config.notrycatch ) {
    +			this.callback.call( this.testEnvironment, QUnit.assert );
    +			this.callbackRuntime = +new Date() - this.callbackStarted;
    +			return;
    +		}
    +
    +		try {
    +			this.callback.call( this.testEnvironment, QUnit.assert );
    +			this.callbackRuntime = +new Date() - this.callbackStarted;
    +		} catch( e ) {
    +			this.callbackRuntime = +new Date() - this.callbackStarted;
    +
    +			QUnit.pushFailure( "Died on test #" + (this.assertions.length + 1) + " " + this.stack + ": " + ( e.message || e ), extractStacktrace( e, 0 ) );
    +			// else next test will carry the responsibility
    +			saveGlobal();
    +
    +			// Restart the tests if they're blocking
    +			if ( config.blocking ) {
    +				QUnit.start();
    +			}
    +		}
    +	},
    +	teardown: function() {
    +		config.current = this;
    +		if ( config.notrycatch ) {
    +			if ( typeof this.callbackRuntime === "undefined" ) {
    +				this.callbackRuntime = +new Date() - this.callbackStarted;
    +			}
    +			this.testEnvironment.teardown.call( this.testEnvironment, QUnit.assert );
    +			return;
    +		} else {
    +			try {
    +				this.testEnvironment.teardown.call( this.testEnvironment, QUnit.assert );
    +			} catch( e ) {
    +				QUnit.pushFailure( "Teardown failed on " + this.testName + ": " + ( e.message || e ), extractStacktrace( e, 1 ) );
    +			}
    +		}
    +		checkPollution();
    +	},
    +	finish: function() {
    +		config.current = this;
    +		if ( config.requireExpects && this.expected === null ) {
    +			QUnit.pushFailure( "Expected number of assertions to be defined, but expect() was not called.", this.stack );
    +		} else if ( this.expected !== null && this.expected !== this.assertions.length ) {
    +			QUnit.pushFailure( "Expected " + this.expected + " assertions, but " + this.assertions.length + " were run", this.stack );
    +		} else if ( this.expected === null && !this.assertions.length ) {
    +			QUnit.pushFailure( "Expected at least one assertion, but none were run - call expect(0) to accept zero assertions.", this.stack );
    +		}
    +
    +		var i, assertion, a, b, time, li, ol,
    +			test = this,
    +			good = 0,
    +			bad = 0,
    +			tests = id( "qunit-tests" );
    +
    +		this.runtime = +new Date() - this.started;
    +		config.stats.all += this.assertions.length;
    +		config.moduleStats.all += this.assertions.length;
    +
    +		if ( tests ) {
    +			ol = document.createElement( "ol" );
    +			ol.className = "qunit-assert-list";
    +
    +			for ( i = 0; i < this.assertions.length; i++ ) {
    +				assertion = this.assertions[i];
    +
    +				li = document.createElement( "li" );
    +				li.className = assertion.result ? "pass" : "fail";
    +				li.innerHTML = assertion.message || ( assertion.result ? "okay" : "failed" );
    +				ol.appendChild( li );
    +
    +				if ( assertion.result ) {
    +					good++;
    +				} else {
    +					bad++;
    +					config.stats.bad++;
    +					config.moduleStats.bad++;
    +				}
    +			}
    +
    +			// store result when possible
    +			if ( QUnit.config.reorder && defined.sessionStorage ) {
    +				if ( bad ) {
    +					sessionStorage.setItem( "qunit-test-" + this.module + "-" + this.testName, bad );
    +				} else {
    +					sessionStorage.removeItem( "qunit-test-" + this.module + "-" + this.testName );
    +				}
    +			}
    +
    +			if ( bad === 0 ) {
    +				addClass( ol, "qunit-collapsed" );
    +			}
    +
    +			// `b` initialized at top of scope
    +			b = document.createElement( "strong" );
    +			b.innerHTML = this.nameHtml + " <b class='counts'>(<b class='failed'>" + bad + "</b>, <b class='passed'>" + good + "</b>, " + this.assertions.length + ")</b>";
    +
    +			addEvent(b, "click", function() {
    +				var next = b.parentNode.lastChild,
    +					collapsed = hasClass( next, "qunit-collapsed" );
    +				( collapsed ? removeClass : addClass )( next, "qunit-collapsed" );
    +			});
    +
    +			addEvent(b, "dblclick", function( e ) {
    +				var target = e && e.target ? e.target : window.event.srcElement;
    +				if ( target.nodeName.toLowerCase() === "span" || target.nodeName.toLowerCase() === "b" ) {
    +					target = target.parentNode;
    +				}
    +				if ( window.location && target.nodeName.toLowerCase() === "strong" ) {
    +					window.location = QUnit.url({ testNumber: test.testNumber });
    +				}
    +			});
    +
    +			// `time` initialized at top of scope
    +			time = document.createElement( "span" );
    +			time.className = "runtime";
    +			time.innerHTML = this.runtime + " ms";
    +
    +			// `li` initialized at top of scope
    +			li = id( this.id );
    +			li.className = bad ? "fail" : "pass";
    +			li.removeChild( li.firstChild );
    +			a = li.firstChild;
    +			li.appendChild( b );
    +			li.appendChild( a );
    +			li.appendChild( time );
    +			li.appendChild( ol );
    +
    +		} else {
    +			for ( i = 0; i < this.assertions.length; i++ ) {
    +				if ( !this.assertions[i].result ) {
    +					bad++;
    +					config.stats.bad++;
    +					config.moduleStats.bad++;
    +				}
    +			}
    +		}
    +
    +		runLoggingCallbacks( "testDone", QUnit, {
    +			name: this.testName,
    +			module: this.module,
    +			failed: bad,
    +			passed: this.assertions.length - bad,
    +			total: this.assertions.length,
    +			duration: this.runtime
    +		});
    +
    +		QUnit.reset();
    +
    +		config.current = undefined;
    +	},
    +
    +	queue: function() {
    +		var bad,
    +			test = this;
    +
    +		synchronize(function() {
    +			test.init();
    +		});
    +		function run() {
    +			// each of these can by async
    +			synchronize(function() {
    +				test.setup();
    +			});
    +			synchronize(function() {
    +				test.run();
    +			});
    +			synchronize(function() {
    +				test.teardown();
    +			});
    +			synchronize(function() {
    +				test.finish();
    +			});
    +		}
    +
    +		// `bad` initialized at top of scope
    +		// defer when previous test run passed, if storage is available
    +		bad = QUnit.config.reorder && defined.sessionStorage &&
    +						+sessionStorage.getItem( "qunit-test-" + this.module + "-" + this.testName );
    +
    +		if ( bad ) {
    +			run();
    +		} else {
    +			synchronize( run, true );
    +		}
    +	}
    +};
    +
    +// Root QUnit object.
    +// `QUnit` initialized at top of scope
    +QUnit = {
    +
    +	// call on start of module test to prepend name to all tests
    +	module: function( name, testEnvironment ) {
    +		config.currentModule = name;
    +		config.currentModuleTestEnvironment = testEnvironment;
    +		config.modules[name] = true;
    +	},
    +
    +	asyncTest: function( testName, expected, callback ) {
    +		if ( arguments.length === 2 ) {
    +			callback = expected;
    +			expected = null;
    +		}
    +
    +		QUnit.test( testName, expected, callback, true );
    +	},
    +
    +	test: function( testName, expected, callback, async ) {
    +		var test,
    +			nameHtml = "<span class='test-name'>" + escapeText( testName ) + "</span>";
    +
    +		if ( arguments.length === 2 ) {
    +			callback = expected;
    +			expected = null;
    +		}
    +
    +		if ( config.currentModule ) {
    +			nameHtml = "<span class='module-name'>" + escapeText( config.currentModule ) + "</span>: " + nameHtml;
    +		}
    +
    +		test = new Test({
    +			nameHtml: nameHtml,
    +			testName: testName,
    +			expected: expected,
    +			async: async,
    +			callback: callback,
    +			module: config.currentModule,
    +			moduleTestEnvironment: config.currentModuleTestEnvironment,
    +			stack: sourceFromStacktrace( 2 )
    +		});
    +
    +		if ( !validTest( test ) ) {
    +			return;
    +		}
    +
    +		test.queue();
    +	},
    +
    +	// Specify the number of expected assertions to guarantee that failed test (no assertions are run at all) don't slip through.
    +	expect: function( asserts ) {
    +		if (arguments.length === 1) {
    +			config.current.expected = asserts;
    +		} else {
    +			return config.current.expected;
    +		}
    +	},
    +
    +	start: function( count ) {
    +		// QUnit hasn't been initialized yet.
    +		// Note: RequireJS (et al) may delay onLoad
    +		if ( config.semaphore === undefined ) {
    +			QUnit.begin(function() {
    +				// This is triggered at the top of QUnit.load, push start() to the event loop, to allow QUnit.load to finish first
    +				setTimeout(function() {
    +					QUnit.start( count );
    +				});
    +			});
    +			return;
    +		}
    +
    +		config.semaphore -= count || 1;
    +		// don't start until equal number of stop-calls
    +		if ( config.semaphore > 0 ) {
    +			return;
    +		}
    +		// ignore if start is called more often then stop
    +		if ( config.semaphore < 0 ) {
    +			config.semaphore = 0;
    +			QUnit.pushFailure( "Called start() while already started (QUnit.config.semaphore was 0 already)", null, sourceFromStacktrace(2) );
    +			return;
    +		}
    +		// A slight delay, to avoid any current callbacks
    +		if ( defined.setTimeout ) {
    +			setTimeout(function() {
    +				if ( config.semaphore > 0 ) {
    +					return;
    +				}
    +				if ( config.timeout ) {
    +					clearTimeout( config.timeout );
    +				}
    +
    +				config.blocking = false;
    +				process( true );
    +			}, 13);
    +		} else {
    +			config.blocking = false;
    +			process( true );
    +		}
    +	},
    +
    +	stop: function( count ) {
    +		config.semaphore += count || 1;
    +		config.blocking = true;
    +
    +		if ( config.testTimeout && defined.setTimeout ) {
    +			clearTimeout( config.timeout );
    +			config.timeout = setTimeout(function() {
    +				QUnit.ok( false, "Test timed out" );
    +				config.semaphore = 1;
    +				QUnit.start();
    +			}, config.testTimeout );
    +		}
    +	}
    +};
    +
    +// `assert` initialized at top of scope
    +// Assert helpers
    +// All of these must either call QUnit.push() or manually do:
    +// - runLoggingCallbacks( "log", .. );
    +// - config.current.assertions.push({ .. });
    +// We attach it to the QUnit object *after* we expose the public API,
    +// otherwise `assert` will become a global variable in browsers (#341).
    +assert = {
    +	/**
    +	 * Asserts rough true-ish result.
    +	 * @name ok
    +	 * @function
    +	 * @example ok( "asdfasdf".length > 5, "There must be at least 5 chars" );
    +	 */
    +	ok: function( result, msg ) {
    +		if ( !config.current ) {
    +			throw new Error( "ok() assertion outside test context, was " + sourceFromStacktrace(2) );
    +		}
    +		result = !!result;
    +		msg = msg || (result ? "okay" : "failed" );
    +
    +		var source,
    +			details = {
    +				module: config.current.module,
    +				name: config.current.testName,
    +				result: result,
    +				message: msg
    +			};
    +
    +		msg = "<span class='test-message'>" + escapeText( msg ) + "</span>";
    +
    +		if ( !result ) {
    +			source = sourceFromStacktrace( 2 );
    +			if ( source ) {
    +				details.source = source;
    +				msg += "<table><tr class='test-source'><th>Source: </th><td><pre>" + escapeText( source ) + "</pre></td></tr></table>";
    +			}
    +		}
    +		runLoggingCallbacks( "log", QUnit, details );
    +		config.current.assertions.push({
    +			result: result,
    +			message: msg
    +		});
    +	},
    +
    +	/**
    +	 * Assert that the first two arguments are equal, with an optional message.
    +	 * Prints out both actual and expected values.
    +	 * @name equal
    +	 * @function
    +	 * @example equal( format( "Received {0} bytes.", 2), "Received 2 bytes.", "format() replaces {0} with next argument" );
    +	 */
    +	equal: function( actual, expected, message ) {
    +		/*jshint eqeqeq:false */
    +		QUnit.push( expected == actual, actual, expected, message );
    +	},
    +
    +	/**
    +	 * @name notEqual
    +	 * @function
    +	 */
    +	notEqual: function( actual, expected, message ) {
    +		/*jshint eqeqeq:false */
    +		QUnit.push( expected != actual, actual, expected, message );
    +	},
    +
    +	/**
    +	 * @name propEqual
    +	 * @function
    +	 */
    +	propEqual: function( actual, expected, message ) {
    +		actual = objectValues(actual);
    +		expected = objectValues(expected);
    +		QUnit.push( QUnit.equiv(actual, expected), actual, expected, message );
    +	},
    +
    +	/**
    +	 * @name notPropEqual
    +	 * @function
    +	 */
    +	notPropEqual: function( actual, expected, message ) {
    +		actual = objectValues(actual);
    +		expected = objectValues(expected);
    +		QUnit.push( !QUnit.equiv(actual, expected), actual, expected, message );
    +	},
    +
    +	/**
    +	 * @name deepEqual
    +	 * @function
    +	 */
    +	deepEqual: function( actual, expected, message ) {
    +		QUnit.push( QUnit.equiv(actual, expected), actual, expected, message );
    +	},
    +
    +	/**
    +	 * @name notDeepEqual
    +	 * @function
    +	 */
    +	notDeepEqual: function( actual, expected, message ) {
    +		QUnit.push( !QUnit.equiv(actual, expected), actual, expected, message );
    +	},
    +
    +	/**
    +	 * @name strictEqual
    +	 * @function
    +	 */
    +	strictEqual: function( actual, expected, message ) {
    +		QUnit.push( expected === actual, actual, expected, message );
    +	},
    +
    +	/**
    +	 * @name notStrictEqual
    +	 * @function
    +	 */
    +	notStrictEqual: function( actual, expected, message ) {
    +		QUnit.push( expected !== actual, actual, expected, message );
    +	},
    +
    +	"throws": function( block, expected, message ) {
    +		var actual,
    +			expectedOutput = expected,
    +			ok = false;
    +
    +		// 'expected' is optional
    +		if ( typeof expected === "string" ) {
    +			message = expected;
    +			expected = null;
    +		}
    +
    +		config.current.ignoreGlobalErrors = true;
    +		try {
    +			block.call( config.current.testEnvironment );
    +		} catch (e) {
    +			actual = e;
    +		}
    +		config.current.ignoreGlobalErrors = false;
    +
    +		if ( actual ) {
    +			// we don't want to validate thrown error
    +			if ( !expected ) {
    +				ok = true;
    +				expectedOutput = null;
    +			// expected is a regexp
    +			} else if ( QUnit.objectType( expected ) === "regexp" ) {
    +				ok = expected.test( errorString( actual ) );
    +			// expected is a constructor
    +			} else if ( actual instanceof expected ) {
    +				ok = true;
    +			// expected is a validation function which returns true is validation passed
    +			} else if ( expected.call( {}, actual ) === true ) {
    +				expectedOutput = null;
    +				ok = true;
    +			}
    +
    +			QUnit.push( ok, actual, expectedOutput, message );
    +		} else {
    +			QUnit.pushFailure( message, null, "No exception was thrown." );
    +		}
    +	}
    +};
    +
    +/**
    + * @deprecated since 1.8.0
    + * Kept assertion helpers in root for backwards compatibility.
    + */
    +extend( QUnit, assert );
    +
    +/**
    + * @deprecated since 1.9.0
    + * Kept root "raises()" for backwards compatibility.
    + * (Note that we don't introduce assert.raises).
    + */
    +QUnit.raises = assert[ "throws" ];
    +
    +/**
    + * @deprecated since 1.0.0, replaced with error pushes since 1.3.0
    + * Kept to avoid TypeErrors for undefined methods.
    + */
    +QUnit.equals = function() {
    +	QUnit.push( false, false, false, "QUnit.equals has been deprecated since 2009 (e88049a0), use QUnit.equal instead" );
    +};
    +QUnit.same = function() {
    +	QUnit.push( false, false, false, "QUnit.same has been deprecated since 2009 (e88049a0), use QUnit.deepEqual instead" );
    +};
    +
    +// We want access to the constructor's prototype
    +(function() {
    +	function F() {}
    +	F.prototype = QUnit;
    +	QUnit = new F();
    +	// Make F QUnit's constructor so that we can add to the prototype later
    +	QUnit.constructor = F;
    +}());
    +
    +/**
    + * Config object: Maintain internal state
    + * Later exposed as QUnit.config
    + * `config` initialized at top of scope
    + */
    +config = {
    +	// The queue of tests to run
    +	queue: [],
    +
    +	// block until document ready
    +	blocking: true,
    +
    +	// when enabled, show only failing tests
    +	// gets persisted through sessionStorage and can be changed in UI via checkbox
    +	hidepassed: false,
    +
    +	// by default, run previously failed tests first
    +	// very useful in combination with "Hide passed tests" checked
    +	reorder: true,
    +
    +	// by default, modify document.title when suite is done
    +	altertitle: true,
    +
    +	// when enabled, all tests must call expect()
    +	requireExpects: false,
    +
    +	// add checkboxes that are persisted in the query-string
    +	// when enabled, the id is set to `true` as a `QUnit.config` property
    +	urlConfig: [
    +		{
    +			id: "noglobals",
    +			label: "Check for Globals",
    +			tooltip: "Enabling this will test if any test introduces new properties on the `window` object. Stored as query-strings."
    +		},
    +		{
    +			id: "notrycatch",
    +			label: "No try-catch",
    +			tooltip: "Enabling this will run tests outside of a try-catch block. Makes debugging exceptions in IE reasonable. Stored as query-strings."
    +		}
    +	],
    +
    +	// Set of all modules.
    +	modules: {},
    +
    +	// logging callback queues
    +	begin: [],
    +	done: [],
    +	log: [],
    +	testStart: [],
    +	testDone: [],
    +	moduleStart: [],
    +	moduleDone: []
    +};
    +
    +// Export global variables, unless an 'exports' object exists,
    +// in that case we assume we're in CommonJS (dealt with on the bottom of the script)
    +if ( typeof exports === "undefined" ) {
    +	extend( window, QUnit.constructor.prototype );
    +
    +	// Expose QUnit object
    +	window.QUnit = QUnit;
    +}
    +
    +// Initialize more QUnit.config and QUnit.urlParams
    +(function() {
    +	var i,
    +		location = window.location || { search: "", protocol: "file:" },
    +		params = location.search.slice( 1 ).split( "&" ),
    +		length = params.length,
    +		urlParams = {},
    +		current;
    +
    +	if ( params[ 0 ] ) {
    +		for ( i = 0; i < length; i++ ) {
    +			current = params[ i ].split( "=" );
    +			current[ 0 ] = decodeURIComponent( current[ 0 ] );
    +			// allow just a key to turn on a flag, e.g., test.html?noglobals
    +			current[ 1 ] = current[ 1 ] ? decodeURIComponent( current[ 1 ] ) : true;
    +			urlParams[ current[ 0 ] ] = current[ 1 ];
    +		}
    +	}
    +
    +	QUnit.urlParams = urlParams;
    +
    +	// String search anywhere in moduleName+testName
    +	config.filter = urlParams.filter;
    +
    +	// Exact match of the module name
    +	config.module = urlParams.module;
    +
    +	config.testNumber = parseInt( urlParams.testNumber, 10 ) || null;
    +
    +	// Figure out if we're running the tests from a server or not
    +	QUnit.isLocal = location.protocol === "file:";
    +}());
    +
    +// Extend QUnit object,
    +// these after set here because they should not be exposed as global functions
    +extend( QUnit, {
    +	assert: assert,
    +
    +	config: config,
    +
    +	// Initialize the configuration options
    +	init: function() {
    +		extend( config, {
    +			stats: { all: 0, bad: 0 },
    +			moduleStats: { all: 0, bad: 0 },
    +			started: +new Date(),
    +			updateRate: 1000,
    +			blocking: false,
    +			autostart: true,
    +			autorun: false,
    +			filter: "",
    +			queue: [],
    +			semaphore: 1
    +		});
    +
    +		var tests, banner, result,
    +			qunit = id( "qunit" );
    +
    +		if ( qunit ) {
    +			qunit.innerHTML =
    +				"<h1 id='qunit-header'>" + escapeText( document.title ) + "</h1>" +
    +				"<h2 id='qunit-banner'></h2>" +
    +				"<div id='qunit-testrunner-toolbar'></div>" +
    +				"<h2 id='qunit-userAgent'></h2>" +
    +				"<ol id='qunit-tests'></ol>";
    +		}
    +
    +		tests = id( "qunit-tests" );
    +		banner = id( "qunit-banner" );
    +		result = id( "qunit-testresult" );
    +
    +		if ( tests ) {
    +			tests.innerHTML = "";
    +		}
    +
    +		if ( banner ) {
    +			banner.className = "";
    +		}
    +
    +		if ( result ) {
    +			result.parentNode.removeChild( result );
    +		}
    +
    +		if ( tests ) {
    +			result = document.createElement( "p" );
    +			result.id = "qunit-testresult";
    +			result.className = "result";
    +			tests.parentNode.insertBefore( result, tests );
    +			result.innerHTML = "Running...<br/>&nbsp;";
    +		}
    +	},
    +
    +	// Resets the test setup. Useful for tests that modify the DOM.
    +	/*
    +	DEPRECATED: Use multiple tests instead of resetting inside a test.
    +	Use testStart or testDone for custom cleanup.
    +	This method will throw an error in 2.0, and will be removed in 2.1
    +	*/
    +	reset: function() {
    +		var fixture = id( "qunit-fixture" );
    +		if ( fixture ) {
    +			fixture.innerHTML = config.fixture;
    +		}
    +	},
    +
    +	// Trigger an event on an element.
    +	// @example triggerEvent( document.body, "click" );
    +	triggerEvent: function( elem, type, event ) {
    +		if ( document.createEvent ) {
    +			event = document.createEvent( "MouseEvents" );
    +			event.initMouseEvent(type, true, true, elem.ownerDocument.defaultView,
    +				0, 0, 0, 0, 0, false, false, false, false, 0, null);
    +
    +			elem.dispatchEvent( event );
    +		} else if ( elem.fireEvent ) {
    +			elem.fireEvent( "on" + type );
    +		}
    +	},
    +
    +	// Safe object type checking
    +	is: function( type, obj ) {
    +		return QUnit.objectType( obj ) === type;
    +	},
    +
    +	objectType: function( obj ) {
    +		if ( typeof obj === "undefined" ) {
    +				return "undefined";
    +		// consider: typeof null === object
    +		}
    +		if ( obj === null ) {
    +				return "null";
    +		}
    +
    +		var match = toString.call( obj ).match(/^\[object\s(.*)\]$/),
    +			type = match && match[1] || "";
    +
    +		switch ( type ) {
    +			case "Number":
    +				if ( isNaN(obj) ) {
    +					return "nan";
    +				}
    +				return "number";
    +			case "String":
    +			case "Boolean":
    +			case "Array":
    +			case "Date":
    +			case "RegExp":
    +			case "Function":
    +				return type.toLowerCase();
    +		}
    +		if ( typeof obj === "object" ) {
    +			return "object";
    +		}
    +		return undefined;
    +	},
    +
    +	push: function( result, actual, expected, message ) {
    +		if ( !config.current ) {
    +			throw new Error( "assertion outside test context, was " + sourceFromStacktrace() );
    +		}
    +
    +		var output, source,
    +			details = {
    +				module: config.current.module,
    +				name: config.current.testName,
    +				result: result,
    +				message: message,
    +				actual: actual,
    +				expected: expected
    +			};
    +
    +		message = escapeText( message ) || ( result ? "okay" : "failed" );
    +		message = "<span class='test-message'>" + message + "</span>";
    +		output = message;
    +
    +		if ( !result ) {
    +			expected = escapeText( QUnit.jsDump.parse(expected) );
    +			actual = escapeText( QUnit.jsDump.parse(actual) );
    +			output += "<table><tr class='test-expected'><th>Expected: </th><td><pre>" + expected + "</pre></td></tr>";
    +
    +			if ( actual !== expected ) {
    +				output += "<tr class='test-actual'><th>Result: </th><td><pre>" + actual + "</pre></td></tr>";
    +				output += "<tr class='test-diff'><th>Diff: </th><td><pre>" + QUnit.diff( expected, actual ) + "</pre></td></tr>";
    +			}
    +
    +			source = sourceFromStacktrace();
    +
    +			if ( source ) {
    +				details.source = source;
    +				output += "<tr class='test-source'><th>Source: </th><td><pre>" + escapeText( source ) + "</pre></td></tr>";
    +			}
    +
    +			output += "</table>";
    +		}
    +
    +		runLoggingCallbacks( "log", QUnit, details );
    +
    +		config.current.assertions.push({
    +			result: !!result,
    +			message: output
    +		});
    +	},
    +
    +	pushFailure: function( message, source, actual ) {
    +		if ( !config.current ) {
    +			throw new Error( "pushFailure() assertion outside test context, was " + sourceFromStacktrace(2) );
    +		}
    +
    +		var output,
    +			details = {
    +				module: config.current.module,
    +				name: config.current.testName,
    +				result: false,
    +				message: message
    +			};
    +
    +		message = escapeText( message ) || "error";
    +		message = "<span class='test-message'>" + message + "</span>";
    +		output = message;
    +
    +		output += "<table>";
    +
    +		if ( actual ) {
    +			output += "<tr class='test-actual'><th>Result: </th><td><pre>" + escapeText( actual ) + "</pre></td></tr>";
    +		}
    +
    +		if ( source ) {
    +			details.source = source;
    +			output += "<tr class='test-source'><th>Source: </th><td><pre>" + escapeText( source ) + "</pre></td></tr>";
    +		}
    +
    +		output += "</table>";
    +
    +		runLoggingCallbacks( "log", QUnit, details );
    +
    +		config.current.assertions.push({
    +			result: false,
    +			message: output
    +		});
    +	},
    +
    +	url: function( params ) {
    +		params = extend( extend( {}, QUnit.urlParams ), params );
    +		var key,
    +			querystring = "?";
    +
    +		for ( key in params ) {
    +			if ( hasOwn.call( params, key ) ) {
    +				querystring += encodeURIComponent( key ) + "=" +
    +					encodeURIComponent( params[ key ] ) + "&";
    +			}
    +		}
    +		return window.location.protocol + "//" + window.location.host +
    +			window.location.pathname + querystring.slice( 0, -1 );
    +	},
    +
    +	extend: extend,
    +	id: id,
    +	addEvent: addEvent,
    +	addClass: addClass,
    +	hasClass: hasClass,
    +	removeClass: removeClass
    +	// load, equiv, jsDump, diff: Attached later
    +});
    +
    +/**
    + * @deprecated: Created for backwards compatibility with test runner that set the hook function
    + * into QUnit.{hook}, instead of invoking it and passing the hook function.
    + * QUnit.constructor is set to the empty F() above so that we can add to it's prototype here.
    + * Doing this allows us to tell if the following methods have been overwritten on the actual
    + * QUnit object.
    + */
    +extend( QUnit.constructor.prototype, {
    +
    +	// Logging callbacks; all receive a single argument with the listed properties
    +	// run test/logs.html for any related changes
    +	begin: registerLoggingCallback( "begin" ),
    +
    +	// done: { failed, passed, total, runtime }
    +	done: registerLoggingCallback( "done" ),
    +
    +	// log: { result, actual, expected, message }
    +	log: registerLoggingCallback( "log" ),
    +
    +	// testStart: { name }
    +	testStart: registerLoggingCallback( "testStart" ),
    +
    +	// testDone: { name, failed, passed, total, duration }
    +	testDone: registerLoggingCallback( "testDone" ),
    +
    +	// moduleStart: { name }
    +	moduleStart: registerLoggingCallback( "moduleStart" ),
    +
    +	// moduleDone: { name, failed, passed, total }
    +	moduleDone: registerLoggingCallback( "moduleDone" )
    +});
    +
    +if ( typeof document === "undefined" || document.readyState === "complete" ) {
    +	config.autorun = true;
    +}
    +
    +QUnit.load = function() {
    +	runLoggingCallbacks( "begin", QUnit, {} );
    +
    +	// Initialize the config, saving the execution queue
    +	var banner, filter, i, label, len, main, ol, toolbar, userAgent, val,
    +		urlConfigCheckboxesContainer, urlConfigCheckboxes, moduleFilter,
    +		numModules = 0,
    +		moduleNames = [],
    +		moduleFilterHtml = "",
    +		urlConfigHtml = "",
    +		oldconfig = extend( {}, config );
    +
    +	QUnit.init();
    +	extend(config, oldconfig);
    +
    +	config.blocking = false;
    +
    +	len = config.urlConfig.length;
    +
    +	for ( i = 0; i < len; i++ ) {
    +		val = config.urlConfig[i];
    +		if ( typeof val === "string" ) {
    +			val = {
    +				id: val,
    +				label: val,
    +				tooltip: "[no tooltip available]"
    +			};
    +		}
    +		config[ val.id ] = QUnit.urlParams[ val.id ];
    +		urlConfigHtml += "<input id='qunit-urlconfig-" + escapeText( val.id ) +
    +			"' name='" + escapeText( val.id ) +
    +			"' type='checkbox'" + ( config[ val.id ] ? " checked='checked'" : "" ) +
    +			" title='" + escapeText( val.tooltip ) +
    +			"'><label for='qunit-urlconfig-" + escapeText( val.id ) +
    +			"' title='" + escapeText( val.tooltip ) + "'>" + val.label + "</label>";
    +	}
    +	for ( i in config.modules ) {
    +		if ( config.modules.hasOwnProperty( i ) ) {
    +			moduleNames.push(i);
    +		}
    +	}
    +	numModules = moduleNames.length;
    +	moduleNames.sort( function( a, b ) {
    +		return a.localeCompare( b );
    +	});
    +	moduleFilterHtml += "<label for='qunit-modulefilter'>Module: </label><select id='qunit-modulefilter' name='modulefilter'><option value='' " +
    +		( config.module === undefined  ? "selected='selected'" : "" ) +
    +		">< All Modules ></option>";
    +
    +
    +	for ( i = 0; i < numModules; i++) {
    +			moduleFilterHtml += "<option value='" + escapeText( encodeURIComponent(moduleNames[i]) ) + "' " +
    +				( config.module === moduleNames[i] ? "selected='selected'" : "" ) +
    +				">" + escapeText(moduleNames[i]) + "</option>";
    +	}
    +	moduleFilterHtml += "</select>";
    +
    +	// `userAgent` initialized at top of scope
    +	userAgent = id( "qunit-userAgent" );
    +	if ( userAgent ) {
    +		userAgent.innerHTML = navigator.userAgent;
    +	}
    +
    +	// `banner` initialized at top of scope
    +	banner = id( "qunit-header" );
    +	if ( banner ) {
    +		banner.innerHTML = "<a href='" + QUnit.url({ filter: undefined, module: undefined, testNumber: undefined }) + "'>" + banner.innerHTML + "</a> ";
    +	}
    +
    +	// `toolbar` initialized at top of scope
    +	toolbar = id( "qunit-testrunner-toolbar" );
    +	if ( toolbar ) {
    +		// `filter` initialized at top of scope
    +		filter = document.createElement( "input" );
    +		filter.type = "checkbox";
    +		filter.id = "qunit-filter-pass";
    +
    +		addEvent( filter, "click", function() {
    +			var tmp,
    +				ol = document.getElementById( "qunit-tests" );
    +
    +			if ( filter.checked ) {
    +				ol.className = ol.className + " hidepass";
    +			} else {
    +				tmp = " " + ol.className.replace( /[\n\t\r]/g, " " ) + " ";
    +				ol.className = tmp.replace( / hidepass /, " " );
    +			}
    +			if ( defined.sessionStorage ) {
    +				if (filter.checked) {
    +					sessionStorage.setItem( "qunit-filter-passed-tests", "true" );
    +				} else {
    +					sessionStorage.removeItem( "qunit-filter-passed-tests" );
    +				}
    +			}
    +		});
    +
    +		if ( config.hidepassed || defined.sessionStorage && sessionStorage.getItem( "qunit-filter-passed-tests" ) ) {
    +			filter.checked = true;
    +			// `ol` initialized at top of scope
    +			ol = document.getElementById( "qunit-tests" );
    +			ol.className = ol.className + " hidepass";
    +		}
    +		toolbar.appendChild( filter );
    +
    +		// `label` initialized at top of scope
    +		label = document.createElement( "label" );
    +		label.setAttribute( "for", "qunit-filter-pass" );
    +		label.setAttribute( "title", "Only show tests and assertions that fail. Stored in sessionStorage." );
    +		label.innerHTML = "Hide passed tests";
    +		toolbar.appendChild( label );
    +
    +		urlConfigCheckboxesContainer = document.createElement("span");
    +		urlConfigCheckboxesContainer.innerHTML = urlConfigHtml;
    +		urlConfigCheckboxes = urlConfigCheckboxesContainer.getElementsByTagName("input");
    +		// For oldIE support:
    +		// * Add handlers to the individual elements instead of the container
    +		// * Use "click" instead of "change"
    +		// * Fallback from event.target to event.srcElement
    +		addEvents( urlConfigCheckboxes, "click", function( event ) {
    +			var params = {},
    +				target = event.target || event.srcElement;
    +			params[ target.name ] = target.checked ? true : undefined;
    +			window.location = QUnit.url( params );
    +		});
    +		toolbar.appendChild( urlConfigCheckboxesContainer );
    +
    +		if (numModules > 1) {
    +			moduleFilter = document.createElement( "span" );
    +			moduleFilter.setAttribute( "id", "qunit-modulefilter-container" );
    +			moduleFilter.innerHTML = moduleFilterHtml;
    +			addEvent( moduleFilter.lastChild, "change", function() {
    +				var selectBox = moduleFilter.getElementsByTagName("select")[0],
    +					selectedModule = decodeURIComponent(selectBox.options[selectBox.selectedIndex].value);
    +
    +				window.location = QUnit.url({
    +					module: ( selectedModule === "" ) ? undefined : selectedModule,
    +					// Remove any existing filters
    +					filter: undefined,
    +					testNumber: undefined
    +				});
    +			});
    +			toolbar.appendChild(moduleFilter);
    +		}
    +	}
    +
    +	// `main` initialized at top of scope
    +	main = id( "qunit-fixture" );
    +	if ( main ) {
    +		config.fixture = main.innerHTML;
    +	}
    +
    +	if ( config.autostart ) {
    +		QUnit.start();
    +	}
    +};
    +
    +addEvent( window, "load", QUnit.load );
    +
    +// `onErrorFnPrev` initialized at top of scope
    +// Preserve other handlers
    +onErrorFnPrev = window.onerror;
    +
    +// Cover uncaught exceptions
    +// Returning true will suppress the default browser handler,
    +// returning false will let it run.
    +window.onerror = function ( error, filePath, linerNr ) {
    +	var ret = false;
    +	if ( onErrorFnPrev ) {
    +		ret = onErrorFnPrev( error, filePath, linerNr );
    +	}
    +
    +	// Treat return value as window.onerror itself does,
    +	// Only do our handling if not suppressed.
    +	if ( ret !== true ) {
    +		if ( QUnit.config.current ) {
    +			if ( QUnit.config.current.ignoreGlobalErrors ) {
    +				return true;
    +			}
    +			QUnit.pushFailure( error, filePath + ":" + linerNr );
    +		} else {
    +			QUnit.test( "global failure", extend( function() {
    +				QUnit.pushFailure( error, filePath + ":" + linerNr );
    +			}, { validTest: validTest } ) );
    +		}
    +		return false;
    +	}
    +
    +	return ret;
    +};
    +
    +function done() {
    +	config.autorun = true;
    +
    +	// Log the last module results
    +	if ( config.currentModule ) {
    +		runLoggingCallbacks( "moduleDone", QUnit, {
    +			name: config.currentModule,
    +			failed: config.moduleStats.bad,
    +			passed: config.moduleStats.all - config.moduleStats.bad,
    +			total: config.moduleStats.all
    +		});
    +	}
    +	delete config.previousModule;
    +
    +	var i, key,
    +		banner = id( "qunit-banner" ),
    +		tests = id( "qunit-tests" ),
    +		runtime = +new Date() - config.started,
    +		passed = config.stats.all - config.stats.bad,
    +		html = [
    +			"Tests completed in ",
    +			runtime,
    +			" milliseconds.<br/>",
    +			"<span class='passed'>",
    +			passed,
    +			"</span> assertions of <span class='total'>",
    +			config.stats.all,
    +			"</span> passed, <span class='failed'>",
    +			config.stats.bad,
    +			"</span> failed."
    +		].join( "" );
    +
    +	if ( banner ) {
    +		banner.className = ( config.stats.bad ? "qunit-fail" : "qunit-pass" );
    +	}
    +
    +	if ( tests ) {
    +		id( "qunit-testresult" ).innerHTML = html;
    +	}
    +
    +	if ( config.altertitle && typeof document !== "undefined" && document.title ) {
    +		// show ✖ for good, ✔ for bad suite result in title
    +		// use escape sequences in case file gets loaded with non-utf-8-charset
    +		document.title = [
    +			( config.stats.bad ? "\u2716" : "\u2714" ),
    +			document.title.replace( /^[\u2714\u2716] /i, "" )
    +		].join( " " );
    +	}
    +
    +	// clear own sessionStorage items if all tests passed
    +	if ( config.reorder && defined.sessionStorage && config.stats.bad === 0 ) {
    +		// `key` & `i` initialized at top of scope
    +		for ( i = 0; i < sessionStorage.length; i++ ) {
    +			key = sessionStorage.key( i++ );
    +			if ( key.indexOf( "qunit-test-" ) === 0 ) {
    +				sessionStorage.removeItem( key );
    +			}
    +		}
    +	}
    +
    +	// scroll back to top to show results
    +	if ( window.scrollTo ) {
    +		window.scrollTo(0, 0);
    +	}
    +
    +	runLoggingCallbacks( "done", QUnit, {
    +		failed: config.stats.bad,
    +		passed: passed,
    +		total: config.stats.all,
    +		runtime: runtime
    +	});
    +}
    +
    +/** @return Boolean: true if this test should be ran */
    +function validTest( test ) {
    +	var include,
    +		filter = config.filter && config.filter.toLowerCase(),
    +		module = config.module && config.module.toLowerCase(),
    +		fullName = (test.module + ": " + test.testName).toLowerCase();
    +
    +	// Internally-generated tests are always valid
    +	if ( test.callback && test.callback.validTest === validTest ) {
    +		delete test.callback.validTest;
    +		return true;
    +	}
    +
    +	if ( config.testNumber ) {
    +		return test.testNumber === config.testNumber;
    +	}
    +
    +	if ( module && ( !test.module || test.module.toLowerCase() !== module ) ) {
    +		return false;
    +	}
    +
    +	if ( !filter ) {
    +		return true;
    +	}
    +
    +	include = filter.charAt( 0 ) !== "!";
    +	if ( !include ) {
    +		filter = filter.slice( 1 );
    +	}
    +
    +	// If the filter matches, we need to honour include
    +	if ( fullName.indexOf( filter ) !== -1 ) {
    +		return include;
    +	}
    +
    +	// Otherwise, do the opposite
    +	return !include;
    +}
    +
    +// so far supports only Firefox, Chrome and Opera (buggy), Safari (for real exceptions)
    +// Later Safari and IE10 are supposed to support error.stack as well
    +// See also https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Error/Stack
    +function extractStacktrace( e, offset ) {
    +	offset = offset === undefined ? 3 : offset;
    +
    +	var stack, include, i;
    +
    +	if ( e.stacktrace ) {
    +		// Opera
    +		return e.stacktrace.split( "\n" )[ offset + 3 ];
    +	} else if ( e.stack ) {
    +		// Firefox, Chrome
    +		stack = e.stack.split( "\n" );
    +		if (/^error$/i.test( stack[0] ) ) {
    +			stack.shift();
    +		}
    +		if ( fileName ) {
    +			include = [];
    +			for ( i = offset; i < stack.length; i++ ) {
    +				if ( stack[ i ].indexOf( fileName ) !== -1 ) {
    +					break;
    +				}
    +				include.push( stack[ i ] );
    +			}
    +			if ( include.length ) {
    +				return include.join( "\n" );
    +			}
    +		}
    +		return stack[ offset ];
    +	} else if ( e.sourceURL ) {
    +		// Safari, PhantomJS
    +		// hopefully one day Safari provides actual stacktraces
    +		// exclude useless self-reference for generated Error objects
    +		if ( /qunit.js$/.test( e.sourceURL ) ) {
    +			return;
    +		}
    +		// for actual exceptions, this is useful
    +		return e.sourceURL + ":" + e.line;
    +	}
    +}
    +function sourceFromStacktrace( offset ) {
    +	try {
    +		throw new Error();
    +	} catch ( e ) {
    +		return extractStacktrace( e, offset );
    +	}
    +}
    +
    +/**
    + * Escape text for attribute or text content.
    + */
    +function escapeText( s ) {
    +	if ( !s ) {
    +		return "";
    +	}
    +	s = s + "";
    +	// Both single quotes and double quotes (for attributes)
    +	return s.replace( /['"<>&]/g, function( s ) {
    +		switch( s ) {
    +			case "'":
    +				return "&#039;";
    +			case "\"":
    +				return "&quot;";
    +			case "<":
    +				return "&lt;";
    +			case ">":
    +				return "&gt;";
    +			case "&":
    +				return "&amp;";
    +		}
    +	});
    +}
    +
    +function synchronize( callback, last ) {
    +	config.queue.push( callback );
    +
    +	if ( config.autorun && !config.blocking ) {
    +		process( last );
    +	}
    +}
    +
    +function process( last ) {
    +	function next() {
    +		process( last );
    +	}
    +	var start = new Date().getTime();
    +	config.depth = config.depth ? config.depth + 1 : 1;
    +
    +	while ( config.queue.length && !config.blocking ) {
    +		if ( !defined.setTimeout || config.updateRate <= 0 || ( ( new Date().getTime() - start ) < config.updateRate ) ) {
    +			config.queue.shift()();
    +		} else {
    +			setTimeout( next, 13 );
    +			break;
    +		}
    +	}
    +	config.depth--;
    +	if ( last && !config.blocking && !config.queue.length && config.depth === 0 ) {
    +		done();
    +	}
    +}
    +
    +function saveGlobal() {
    +	config.pollution = [];
    +
    +	if ( config.noglobals ) {
    +		for ( var key in window ) {
    +			if ( hasOwn.call( window, key ) ) {
    +				// in Opera sometimes DOM element ids show up here, ignore them
    +				if ( /^qunit-test-output/.test( key ) ) {
    +					continue;
    +				}
    +				config.pollution.push( key );
    +			}
    +		}
    +	}
    +}
    +
    +function checkPollution() {
    +	var newGlobals,
    +		deletedGlobals,
    +		old = config.pollution;
    +
    +	saveGlobal();
    +
    +	newGlobals = diff( config.pollution, old );
    +	if ( newGlobals.length > 0 ) {
    +		QUnit.pushFailure( "Introduced global variable(s): " + newGlobals.join(", ") );
    +	}
    +
    +	deletedGlobals = diff( old, config.pollution );
    +	if ( deletedGlobals.length > 0 ) {
    +		QUnit.pushFailure( "Deleted global variable(s): " + deletedGlobals.join(", ") );
    +	}
    +}
    +
    +// returns a new Array with the elements that are in a but not in b
    +function diff( a, b ) {
    +	var i, j,
    +		result = a.slice();
    +
    +	for ( i = 0; i < result.length; i++ ) {
    +		for ( j = 0; j < b.length; j++ ) {
    +			if ( result[i] === b[j] ) {
    +				result.splice( i, 1 );
    +				i--;
    +				break;
    +			}
    +		}
    +	}
    +	return result;
    +}
    +
    +function extend( a, b ) {
    +	for ( var prop in b ) {
    +		if ( hasOwn.call( b, prop ) ) {
    +			// Avoid "Member not found" error in IE8 caused by messing with window.constructor
    +			if ( !( prop === "constructor" && a === window ) ) {
    +				if ( b[ prop ] === undefined ) {
    +					delete a[ prop ];
    +				} else {
    +					a[ prop ] = b[ prop ];
    +				}
    +			}
    +		}
    +	}
    +
    +	return a;
    +}
    +
    +/**
    + * @param {HTMLElement} elem
    + * @param {string} type
    + * @param {Function} fn
    + */
    +function addEvent( elem, type, fn ) {
    +	// Standards-based browsers
    +	if ( elem.addEventListener ) {
    +		elem.addEventListener( type, fn, false );
    +	// IE
    +	} else {
    +		elem.attachEvent( "on" + type, fn );
    +	}
    +}
    +
    +/**
    + * @param {Array|NodeList} elems
    + * @param {string} type
    + * @param {Function} fn
    + */
    +function addEvents( elems, type, fn ) {
    +	var i = elems.length;
    +	while ( i-- ) {
    +		addEvent( elems[i], type, fn );
    +	}
    +}
    +
    +function hasClass( elem, name ) {
    +	return (" " + elem.className + " ").indexOf(" " + name + " ") > -1;
    +}
    +
    +function addClass( elem, name ) {
    +	if ( !hasClass( elem, name ) ) {
    +		elem.className += (elem.className ? " " : "") + name;
    +	}
    +}
    +
    +function removeClass( elem, name ) {
    +	var set = " " + elem.className + " ";
    +	// Class name may appear multiple times
    +	while ( set.indexOf(" " + name + " ") > -1 ) {
    +		set = set.replace(" " + name + " " , " ");
    +	}
    +	// If possible, trim it for prettiness, but not necessarily
    +	elem.className = typeof set.trim === "function" ? set.trim() : set.replace(/^\s+|\s+$/g, "");
    +}
    +
    +function id( name ) {
    +	return !!( typeof document !== "undefined" && document && document.getElementById ) &&
    +		document.getElementById( name );
    +}
    +
    +function registerLoggingCallback( key ) {
    +	return function( callback ) {
    +		config[key].push( callback );
    +	};
    +}
    +
    +// Supports deprecated method of completely overwriting logging callbacks
    +function runLoggingCallbacks( key, scope, args ) {
    +	var i, callbacks;
    +	if ( QUnit.hasOwnProperty( key ) ) {
    +		QUnit[ key ].call(scope, args );
    +	} else {
    +		callbacks = config[ key ];
    +		for ( i = 0; i < callbacks.length; i++ ) {
    +			callbacks[ i ].call( scope, args );
    +		}
    +	}
    +}
    +
    +// Test for equality any JavaScript type.
    +// Author: Philippe Rathé <prathe@gmail.com>
    +QUnit.equiv = (function() {
    +
    +	// Call the o related callback with the given arguments.
    +	function bindCallbacks( o, callbacks, args ) {
    +		var prop = QUnit.objectType( o );
    +		if ( prop ) {
    +			if ( QUnit.objectType( callbacks[ prop ] ) === "function" ) {
    +				return callbacks[ prop ].apply( callbacks, args );
    +			} else {
    +				return callbacks[ prop ]; // or undefined
    +			}
    +		}
    +	}
    +
    +	// the real equiv function
    +	var innerEquiv,
    +		// stack to decide between skip/abort functions
    +		callers = [],
    +		// stack to avoiding loops from circular referencing
    +		parents = [],
    +		parentsB = [],
    +
    +		getProto = Object.getPrototypeOf || function ( obj ) {
    +			/*jshint camelcase:false */
    +			return obj.__proto__;
    +		},
    +		callbacks = (function () {
    +
    +			// for string, boolean, number and null
    +			function useStrictEquality( b, a ) {
    +				/*jshint eqeqeq:false */
    +				if ( b instanceof a.constructor || a instanceof b.constructor ) {
    +					// to catch short annotation VS 'new' annotation of a
    +					// declaration
    +					// e.g. var i = 1;
    +					// var j = new Number(1);
    +					return a == b;
    +				} else {
    +					return a === b;
    +				}
    +			}
    +
    +			return {
    +				"string": useStrictEquality,
    +				"boolean": useStrictEquality,
    +				"number": useStrictEquality,
    +				"null": useStrictEquality,
    +				"undefined": useStrictEquality,
    +
    +				"nan": function( b ) {
    +					return isNaN( b );
    +				},
    +
    +				"date": function( b, a ) {
    +					return QUnit.objectType( b ) === "date" && a.valueOf() === b.valueOf();
    +				},
    +
    +				"regexp": function( b, a ) {
    +					return QUnit.objectType( b ) === "regexp" &&
    +						// the regex itself
    +						a.source === b.source &&
    +						// and its modifiers
    +						a.global === b.global &&
    +						// (gmi) ...
    +						a.ignoreCase === b.ignoreCase &&
    +						a.multiline === b.multiline &&
    +						a.sticky === b.sticky;
    +				},
    +
    +				// - skip when the property is a method of an instance (OOP)
    +				// - abort otherwise,
    +				// initial === would have catch identical references anyway
    +				"function": function() {
    +					var caller = callers[callers.length - 1];
    +					return caller !== Object && typeof caller !== "undefined";
    +				},
    +
    +				"array": function( b, a ) {
    +					var i, j, len, loop, aCircular, bCircular;
    +
    +					// b could be an object literal here
    +					if ( QUnit.objectType( b ) !== "array" ) {
    +						return false;
    +					}
    +
    +					len = a.length;
    +					if ( len !== b.length ) {
    +						// safe and faster
    +						return false;
    +					}
    +
    +					// track reference to avoid circular references
    +					parents.push( a );
    +					parentsB.push( b );
    +					for ( i = 0; i < len; i++ ) {
    +						loop = false;
    +						for ( j = 0; j < parents.length; j++ ) {
    +							aCircular = parents[j] === a[i];
    +							bCircular = parentsB[j] === b[i];
    +							if ( aCircular || bCircular ) {
    +								if ( a[i] === b[i] || aCircular && bCircular ) {
    +									loop = true;
    +								} else {
    +									parents.pop();
    +									parentsB.pop();
    +									return false;
    +								}
    +							}
    +						}
    +						if ( !loop && !innerEquiv(a[i], b[i]) ) {
    +							parents.pop();
    +							parentsB.pop();
    +							return false;
    +						}
    +					}
    +					parents.pop();
    +					parentsB.pop();
    +					return true;
    +				},
    +
    +				"object": function( b, a ) {
    +					/*jshint forin:false */
    +					var i, j, loop, aCircular, bCircular,
    +						// Default to true
    +						eq = true,
    +						aProperties = [],
    +						bProperties = [];
    +
    +					// comparing constructors is more strict than using
    +					// instanceof
    +					if ( a.constructor !== b.constructor ) {
    +						// Allow objects with no prototype to be equivalent to
    +						// objects with Object as their constructor.
    +						if ( !(( getProto(a) === null && getProto(b) === Object.prototype ) ||
    +							( getProto(b) === null && getProto(a) === Object.prototype ) ) ) {
    +								return false;
    +						}
    +					}
    +
    +					// stack constructor before traversing properties
    +					callers.push( a.constructor );
    +
    +					// track reference to avoid circular references
    +					parents.push( a );
    +					parentsB.push( b );
    +
    +					// be strict: don't ensure hasOwnProperty and go deep
    +					for ( i in a ) {
    +						loop = false;
    +						for ( j = 0; j < parents.length; j++ ) {
    +							aCircular = parents[j] === a[i];
    +							bCircular = parentsB[j] === b[i];
    +							if ( aCircular || bCircular ) {
    +								if ( a[i] === b[i] || aCircular && bCircular ) {
    +									loop = true;
    +								} else {
    +									eq = false;
    +									break;
    +								}
    +							}
    +						}
    +						aProperties.push(i);
    +						if ( !loop && !innerEquiv(a[i], b[i]) ) {
    +							eq = false;
    +							break;
    +						}
    +					}
    +
    +					parents.pop();
    +					parentsB.pop();
    +					callers.pop(); // unstack, we are done
    +
    +					for ( i in b ) {
    +						bProperties.push( i ); // collect b's properties
    +					}
    +
    +					// Ensures identical properties name
    +					return eq && innerEquiv( aProperties.sort(), bProperties.sort() );
    +				}
    +			};
    +		}());
    +
    +	innerEquiv = function() { // can take multiple arguments
    +		var args = [].slice.apply( arguments );
    +		if ( args.length < 2 ) {
    +			return true; // end transition
    +		}
    +
    +		return (function( a, b ) {
    +			if ( a === b ) {
    +				return true; // catch the most you can
    +			} else if ( a === null || b === null || typeof a === "undefined" ||
    +					typeof b === "undefined" ||
    +					QUnit.objectType(a) !== QUnit.objectType(b) ) {
    +				return false; // don't lose time with error prone cases
    +			} else {
    +				return bindCallbacks(a, callbacks, [ b, a ]);
    +			}
    +
    +			// apply transition with (1..n) arguments
    +		}( args[0], args[1] ) && innerEquiv.apply( this, args.splice(1, args.length - 1 )) );
    +	};
    +
    +	return innerEquiv;
    +}());
    +
    +/**
    + * jsDump Copyright (c) 2008 Ariel Flesler - aflesler(at)gmail(dot)com |
    + * http://flesler.blogspot.com Licensed under BSD
    + * (http://www.opensource.org/licenses/bsd-license.php) Date: 5/15/2008
    + *
    + * @projectDescription Advanced and extensible data dumping for Javascript.
    + * @version 1.0.0
    + * @author Ariel Flesler
    + * @link {http://flesler.blogspot.com/2008/05/jsdump-pretty-dump-of-any-javascript.html}
    + */
    +QUnit.jsDump = (function() {
    +	function quote( str ) {
    +		return "\"" + str.toString().replace( /"/g, "\\\"" ) + "\"";
    +	}
    +	function literal( o ) {
    +		return o + "";
    +	}
    +	function join( pre, arr, post ) {
    +		var s = jsDump.separator(),
    +			base = jsDump.indent(),
    +			inner = jsDump.indent(1);
    +		if ( arr.join ) {
    +			arr = arr.join( "," + s + inner );
    +		}
    +		if ( !arr ) {
    +			return pre + post;
    +		}
    +		return [ pre, inner + arr, base + post ].join(s);
    +	}
    +	function array( arr, stack ) {
    +		var i = arr.length, ret = new Array(i);
    +		this.up();
    +		while ( i-- ) {
    +			ret[i] = this.parse( arr[i] , undefined , stack);
    +		}
    +		this.down();
    +		return join( "[", ret, "]" );
    +	}
    +
    +	var reName = /^function (\w+)/,
    +		jsDump = {
    +			// type is used mostly internally, you can fix a (custom)type in advance
    +			parse: function( obj, type, stack ) {
    +				stack = stack || [ ];
    +				var inStack, res,
    +					parser = this.parsers[ type || this.typeOf(obj) ];
    +
    +				type = typeof parser;
    +				inStack = inArray( obj, stack );
    +
    +				if ( inStack !== -1 ) {
    +					return "recursion(" + (inStack - stack.length) + ")";
    +				}
    +				if ( type === "function" )  {
    +					stack.push( obj );
    +					res = parser.call( this, obj, stack );
    +					stack.pop();
    +					return res;
    +				}
    +				return ( type === "string" ) ? parser : this.parsers.error;
    +			},
    +			typeOf: function( obj ) {
    +				var type;
    +				if ( obj === null ) {
    +					type = "null";
    +				} else if ( typeof obj === "undefined" ) {
    +					type = "undefined";
    +				} else if ( QUnit.is( "regexp", obj) ) {
    +					type = "regexp";
    +				} else if ( QUnit.is( "date", obj) ) {
    +					type = "date";
    +				} else if ( QUnit.is( "function", obj) ) {
    +					type = "function";
    +				} else if ( typeof obj.setInterval !== undefined && typeof obj.document !== "undefined" && typeof obj.nodeType === "undefined" ) {
    +					type = "window";
    +				} else if ( obj.nodeType === 9 ) {
    +					type = "document";
    +				} else if ( obj.nodeType ) {
    +					type = "node";
    +				} else if (
    +					// native arrays
    +					toString.call( obj ) === "[object Array]" ||
    +					// NodeList objects
    +					( typeof obj.length === "number" && typeof obj.item !== "undefined" && ( obj.length ? obj.item(0) === obj[0] : ( obj.item( 0 ) === null && typeof obj[0] === "undefined" ) ) )
    +				) {
    +					type = "array";
    +				} else if ( obj.constructor === Error.prototype.constructor ) {
    +					type = "error";
    +				} else {
    +					type = typeof obj;
    +				}
    +				return type;
    +			},
    +			separator: function() {
    +				return this.multiline ?	this.HTML ? "<br />" : "\n" : this.HTML ? "&nbsp;" : " ";
    +			},
    +			// extra can be a number, shortcut for increasing-calling-decreasing
    +			indent: function( extra ) {
    +				if ( !this.multiline ) {
    +					return "";
    +				}
    +				var chr = this.indentChar;
    +				if ( this.HTML ) {
    +					chr = chr.replace( /\t/g, "   " ).replace( / /g, "&nbsp;" );
    +				}
    +				return new Array( this.depth + ( extra || 0 ) ).join(chr);
    +			},
    +			up: function( a ) {
    +				this.depth += a || 1;
    +			},
    +			down: function( a ) {
    +				this.depth -= a || 1;
    +			},
    +			setParser: function( name, parser ) {
    +				this.parsers[name] = parser;
    +			},
    +			// The next 3 are exposed so you can use them
    +			quote: quote,
    +			literal: literal,
    +			join: join,
    +			//
    +			depth: 1,
    +			// This is the list of parsers, to modify them, use jsDump.setParser
    +			parsers: {
    +				window: "[Window]",
    +				document: "[Document]",
    +				error: function(error) {
    +					return "Error(\"" + error.message + "\")";
    +				},
    +				unknown: "[Unknown]",
    +				"null": "null",
    +				"undefined": "undefined",
    +				"function": function( fn ) {
    +					var ret = "function",
    +						// functions never have name in IE
    +						name = "name" in fn ? fn.name : (reName.exec(fn) || [])[1];
    +
    +					if ( name ) {
    +						ret += " " + name;
    +					}
    +					ret += "( ";
    +
    +					ret = [ ret, QUnit.jsDump.parse( fn, "functionArgs" ), "){" ].join( "" );
    +					return join( ret, QUnit.jsDump.parse(fn,"functionCode" ), "}" );
    +				},
    +				array: array,
    +				nodelist: array,
    +				"arguments": array,
    +				object: function( map, stack ) {
    +					/*jshint forin:false */
    +					var ret = [ ], keys, key, val, i;
    +					QUnit.jsDump.up();
    +					keys = [];
    +					for ( key in map ) {
    +						keys.push( key );
    +					}
    +					keys.sort();
    +					for ( i = 0; i < keys.length; i++ ) {
    +						key = keys[ i ];
    +						val = map[ key ];
    +						ret.push( QUnit.jsDump.parse( key, "key" ) + ": " + QUnit.jsDump.parse( val, undefined, stack ) );
    +					}
    +					QUnit.jsDump.down();
    +					return join( "{", ret, "}" );
    +				},
    +				node: function( node ) {
    +					var len, i, val,
    +						open = QUnit.jsDump.HTML ? "&lt;" : "<",
    +						close = QUnit.jsDump.HTML ? "&gt;" : ">",
    +						tag = node.nodeName.toLowerCase(),
    +						ret = open + tag,
    +						attrs = node.attributes;
    +
    +					if ( attrs ) {
    +						for ( i = 0, len = attrs.length; i < len; i++ ) {
    +							val = attrs[i].nodeValue;
    +							// IE6 includes all attributes in .attributes, even ones not explicitly set.
    +							// Those have values like undefined, null, 0, false, "" or "inherit".
    +							if ( val && val !== "inherit" ) {
    +								ret += " " + attrs[i].nodeName + "=" + QUnit.jsDump.parse( val, "attribute" );
    +							}
    +						}
    +					}
    +					ret += close;
    +
    +					// Show content of TextNode or CDATASection
    +					if ( node.nodeType === 3 || node.nodeType === 4 ) {
    +						ret += node.nodeValue;
    +					}
    +
    +					return ret + open + "/" + tag + close;
    +				},
    +				// function calls it internally, it's the arguments part of the function
    +				functionArgs: function( fn ) {
    +					var args,
    +						l = fn.length;
    +
    +					if ( !l ) {
    +						return "";
    +					}
    +
    +					args = new Array(l);
    +					while ( l-- ) {
    +						// 97 is 'a'
    +						args[l] = String.fromCharCode(97+l);
    +					}
    +					return " " + args.join( ", " ) + " ";
    +				},
    +				// object calls it internally, the key part of an item in a map
    +				key: quote,
    +				// function calls it internally, it's the content of the function
    +				functionCode: "[code]",
    +				// node calls it internally, it's an html attribute value
    +				attribute: quote,
    +				string: quote,
    +				date: quote,
    +				regexp: literal,
    +				number: literal,
    +				"boolean": literal
    +			},
    +			// if true, entities are escaped ( <, >, \t, space and \n )
    +			HTML: false,
    +			// indentation unit
    +			indentChar: "  ",
    +			// if true, items in a collection, are separated by a \n, else just a space.
    +			multiline: true
    +		};
    +
    +	return jsDump;
    +}());
    +
    +// from jquery.js
    +function inArray( elem, array ) {
    +	if ( array.indexOf ) {
    +		return array.indexOf( elem );
    +	}
    +
    +	for ( var i = 0, length = array.length; i < length; i++ ) {
    +		if ( array[ i ] === elem ) {
    +			return i;
    +		}
    +	}
    +
    +	return -1;
    +}
    +
    +/*
    + * Javascript Diff Algorithm
    + *  By John Resig (http://ejohn.org/)
    + *  Modified by Chu Alan "sprite"
    + *
    + * Released under the MIT license.
    + *
    + * More Info:
    + *  http://ejohn.org/projects/javascript-diff-algorithm/
    + *
    + * Usage: QUnit.diff(expected, actual)
    + *
    + * QUnit.diff( "the quick brown fox jumped over", "the quick fox jumps over" ) == "the  quick <del>brown </del> fox <del>jumped </del><ins>jumps </ins> over"
    + */
    +QUnit.diff = (function() {
    +	/*jshint eqeqeq:false, eqnull:true */
    +	function diff( o, n ) {
    +		var i,
    +			ns = {},
    +			os = {};
    +
    +		for ( i = 0; i < n.length; i++ ) {
    +			if ( !hasOwn.call( ns, n[i] ) ) {
    +				ns[ n[i] ] = {
    +					rows: [],
    +					o: null
    +				};
    +			}
    +			ns[ n[i] ].rows.push( i );
    +		}
    +
    +		for ( i = 0; i < o.length; i++ ) {
    +			if ( !hasOwn.call( os, o[i] ) ) {
    +				os[ o[i] ] = {
    +					rows: [],
    +					n: null
    +				};
    +			}
    +			os[ o[i] ].rows.push( i );
    +		}
    +
    +		for ( i in ns ) {
    +			if ( hasOwn.call( ns, i ) ) {
    +				if ( ns[i].rows.length === 1 && hasOwn.call( os, i ) && os[i].rows.length === 1 ) {
    +					n[ ns[i].rows[0] ] = {
    +						text: n[ ns[i].rows[0] ],
    +						row: os[i].rows[0]
    +					};
    +					o[ os[i].rows[0] ] = {
    +						text: o[ os[i].rows[0] ],
    +						row: ns[i].rows[0]
    +					};
    +				}
    +			}
    +		}
    +
    +		for ( i = 0; i < n.length - 1; i++ ) {
    +			if ( n[i].text != null && n[ i + 1 ].text == null && n[i].row + 1 < o.length && o[ n[i].row + 1 ].text == null &&
    +						n[ i + 1 ] == o[ n[i].row + 1 ] ) {
    +
    +				n[ i + 1 ] = {
    +					text: n[ i + 1 ],
    +					row: n[i].row + 1
    +				};
    +				o[ n[i].row + 1 ] = {
    +					text: o[ n[i].row + 1 ],
    +					row: i + 1
    +				};
    +			}
    +		}
    +
    +		for ( i = n.length - 1; i > 0; i-- ) {
    +			if ( n[i].text != null && n[ i - 1 ].text == null && n[i].row > 0 && o[ n[i].row - 1 ].text == null &&
    +						n[ i - 1 ] == o[ n[i].row - 1 ]) {
    +
    +				n[ i - 1 ] = {
    +					text: n[ i - 1 ],
    +					row: n[i].row - 1
    +				};
    +				o[ n[i].row - 1 ] = {
    +					text: o[ n[i].row - 1 ],
    +					row: i - 1
    +				};
    +			}
    +		}
    +
    +		return {
    +			o: o,
    +			n: n
    +		};
    +	}
    +
    +	return function( o, n ) {
    +		o = o.replace( /\s+$/, "" );
    +		n = n.replace( /\s+$/, "" );
    +
    +		var i, pre,
    +			str = "",
    +			out = diff( o === "" ? [] : o.split(/\s+/), n === "" ? [] : n.split(/\s+/) ),
    +			oSpace = o.match(/\s+/g),
    +			nSpace = n.match(/\s+/g);
    +
    +		if ( oSpace == null ) {
    +			oSpace = [ " " ];
    +		}
    +		else {
    +			oSpace.push( " " );
    +		}
    +
    +		if ( nSpace == null ) {
    +			nSpace = [ " " ];
    +		}
    +		else {
    +			nSpace.push( " " );
    +		}
    +
    +		if ( out.n.length === 0 ) {
    +			for ( i = 0; i < out.o.length; i++ ) {
    +				str += "<del>" + out.o[i] + oSpace[i] + "</del>";
    +			}
    +		}
    +		else {
    +			if ( out.n[0].text == null ) {
    +				for ( n = 0; n < out.o.length && out.o[n].text == null; n++ ) {
    +					str += "<del>" + out.o[n] + oSpace[n] + "</del>";
    +				}
    +			}
    +
    +			for ( i = 0; i < out.n.length; i++ ) {
    +				if (out.n[i].text == null) {
    +					str += "<ins>" + out.n[i] + nSpace[i] + "</ins>";
    +				}
    +				else {
    +					// `pre` initialized at top of scope
    +					pre = "";
    +
    +					for ( n = out.n[i].row + 1; n < out.o.length && out.o[n].text == null; n++ ) {
    +						pre += "<del>" + out.o[n] + oSpace[n] + "</del>";
    +					}
    +					str += " " + out.n[i].text + nSpace[i] + pre;
    +				}
    +			}
    +		}
    +
    +		return str;
    +	};
    +}());
    +
    +// for CommonJS environments, export everything
    +if ( typeof exports !== "undefined" ) {
    +	extend( exports, QUnit.constructor.prototype );
    +}
    +
    +// get at whatever the global object is, like window in browsers
    +}( (function() {return this;}.call()) ));
    diff --git a/html/allsky/virtualsky/galaxy.json b/html/allsky/virtualsky/galaxy.json
    new file mode 100755
    index 000000000..0a8a5c471
    --- /dev/null
    +++ b/html/allsky/virtualsky/galaxy.json
    @@ -0,0 +1,13 @@
    +{
    +	"galaxy": [
    +		["#396bad",254.1778,-21.5124,253.6146,-22.5310,253.2638,-27.7008,251.9368,-29.7742,249.7919,-29.7867,247.8000,-28.4406,246.5570,-26.9571,244.6278,-25.8787,242.5067,-26.0070,241.5945,-25.3687,240.7208,-23.0525,239.5973,-22.3533,238.6863,-23.5322,238.7180,-25.7313,239.4131,-28.0723,238.6475,-30.8747,239.9512,-34.4963,240.4545,-37.0030,238.8220,-39.1709,235.6101,-41.6744,234.5926,-42.9833,235.6221,-44.6917,235.5743,-46.4283,232.6913,-48.4186,228.3087,-49.7552,225.9823,-50.6267,221.4249,-52.5289,216.0338,-54.6596,209.0229,-55.3209,202.9102,-56.0362,196.2626,-56.3616,191.7337,-57.1169,184.7426,-59.1857,180.5514,-58.8402,175.1997,-58.5802,170.6339,-57.6749,168.3549,-56.2380,163.3607,-54.7001,159.2106,-51.3301,154.8495,-47.2975,150.0574,-45.2411,146.8211,-44.7221,145.5418,-45.4794,145.6709,-46.1571,149.5391,-48.3145,150.7915,-49.6707,150.0747,-51.0247,147.4159,-49.8497,144.3472,-47.8592,142.7713,-47.5886,142.7304,-48.1825,141.8590,-48.7921,143.8702,-51.7563,146.7533,-53.8733,146.7337,-54.7900,145.4937,-55.0315,136.3994,-53.7185,133.1890,-53.9932,132.3952,-55.2722,133.9782,-56.3603,137.5581,-56.8447,142.3823,-56.7640,147.1253,-56.7384,149.2668,-57.3424,151.0831,-57.4354,153.9265,-58.4216,157.9421,-58.9774,159.0422,-59.6771,157.5067,-60.3507,155.1830,-59.7783,151.9601,-59.2083,149.2887,-59.3163,148.3379,-59.9243,149.9157,-61.4340,154.0475,-62.1249,155.9994,-62.2575,159.2582,-63.5263,165.5091,-63.4204,167.1619,-62.8290,170.1596,-61.3662,172.9661,-61.4156,176.0518,-62.9133,179.3437,-64.2607,181.9387,-65.3282,184.3779,-69.3924,188.2568,-70.9570,194.7347,-71.2607,199.7570,-70.2394,204.1730,-68.8427,209.0938,-67.8511,216.7510,-68.0095,221.9748,-67.1396,227.5494,-66.2461,234.5158,-64.3854,240.5233,-61.8468,244.0983,-60.4173,251.3801,-59.1124,253.2926,-57.4685,254.7774,-56.3244,261.4819,-54.4899,265.8676,-50.8482,268.8085,-48.0793,269.9543,-46.3469,270.5405,-43.6313,270.6492,-42.3809,271.0668,-41.1083,273.1271,-38.6387,274.8287,-36.5477,276.5511,-33.8477,275.4040,-33.1596,275.2727,-30.5290,276.8968,-28.1703,277.9533,-25.4297,278.6386,-22.3113,279.4081,-19.7351,280.9230,-17.4472,281.9964,-15.6719,282.8788,-13.6664,283.3409,-11.0282,284.5659,-8.4787,285.8740,-5.6156,287.7313,-3.1057,289.7038,-1.0941,290.4044,-0.0164,290.7342,1.1813,291.9363,0.4122,292.8626,0.7917,295.5690,0.8033,297.0695,1.1497,297.0874,2.1407,295.9694,2.3815,294.6764,2.5679,294.4415,3.7745,295.0541,5.1814,296.9021,5.9879,297.7330,6.5454,297.6053,8.4351,298.6240,10.2040,298.1628,13.5562,299.1257,15.5289,302.8713,15.4981,305.5156,16.1286,308.1366,16.6469,308.7496,18.5262,307.5513,19.8431,307.7080,20.7394,309.0176,21.5802,308.7945,23.5149,309.4605,24.2478,310.5891,24.9863,310.3480,26.0982,308.9284,26.8788,309.1600,27.5971,311.8741,29.1431,313.3279,32.0552,313.9435,35.1102,314.9513,36.1769,319.2157,38.0107,320.5720,39.7440,321.0403,40.9232,321.8310,41.6873,323.6238,44.8754,325.1386,46.0919,328.0798,46.8738,332.6317,47.1238,338.1292,47.4766,344.7038,47.6502,346.4796,47.7702,351.3753,46.7280,355.1420,46.2097,359.5920,46.1929,4.3035,46.2481,8.0516,45.6862,10.5201,45.7218,12.3781,45.1055,16.3043,45.2972,19.0389,44.8213,23.6309,45.0223,30.7979,43.0638,32.8210,42.8635,40.6571,43.7653,43.6189,43.8484,46.6891,42.5931,45.3773,41.6641,41.7099,41.8080,39.3969,41.6311,36.5579,40.8644,36.3328,39.9578,39.0080,38.9254,43.4828,39.0665,47.0254,39.2746,50.5370,39.3676,53.6788,39.5428,55.7670,38.8688,57.1481,37.3216,57.2361,35.7482,56.6404,33.7143,57.1765,32.1478,58.8043,30.8405,59.9439,30.9968,61.4966,33.3066,61.4994,36.0547,61.1808,38.6650,61.7684,40.0026,63.8402,40.1004,71.4989,34.6769,75.3343,31.9184,79.7168,29.0407,81.0519,25.1195,82.1320,22.0941,83.6308,17.4698,85.5679,15.1690,86.9568,12.6324,87.7444,8.8192,91.3008,6.1831,92.8266,2.6539,94.6923,-2.1965,97.7264,-6.0265,102.5525,-9.6439,104.6125,-11.6416,105.1552,-14.2536,108.2256,-18.5894,109.6456,-25.3483,111.2200,-27.2135,111.2669,-32.1720,114.7741,-37.8718,116.9787,-42.4014,117.0836,-45.1959,116.1853,-48.6868,118.8947,-50.1703,123.5005,-49.9484,125.9819,-48.8761,122.1675,-44.3179,122.4135,-42.9137,124.6591,-42.8292,129.1849,-46.1315,132.5020,-47.4562,134.3955,-47.4750,135.7144,-46.3461,131.5066,-42.8910,132.0107,-42.0135,137.8183,-45.2697,139.3131,-45.0639,139.6524,-44.1094,136.9034,-40.6399,136.6626,-38.9913,139.6753,-40.1504,144.8151,-42.6899,150.5786,-42.3526,153.6316,-43.0367,155.7573,-42.5342,156.1233,-41.0904,152.5690,-38.5605,148.4971,-37.7767,141.2865,-38.2886,135.1421,-35.9600,129.8501,-32.1764,126.2320,-28.4253,122.1594,-21.3427,118.5304,-12.7577,116.3335,-8.7013,112.8556,-6.4334,109.1321,-2.3025,108.1548,0.0096,107.9889,5.4587,106.7266,8.9737,103.4908,11.8896,101.3959,15.0964,99.5314,17.7842,97.6915,19.7759,96.9057,21.2016,90.6296,24.9510,88.0744,27.4918,86.0675,31.0217,85.2336,32.7789,81.6446,36.8533,79.8386,39.1558,78.6539,44.2051,77.5234,45.5296,73.0840,46.1406,69.0884,46.5652,66.4784,48.5322,65.0187,50.6773,63.1096,51.4737,60.4311,51.8441,60.2604,52.7052,63.6488,52.7609,65.6099,53.0495,68.0178,52.7517,68.0826,53.7533,63.1671,54.9872,57.4967,55.4868,58.4393,56.1385,60.5618,56.2369,62.6651,56.7553,62.6924,57.7129,59.3107,59.2013,50.7444,59.4785,45.7503,59.6435,43.3097,60.6511,38.7256,61.5348,35.7107,62.5099,30.2614,62.7058,27.5803,63.4145,23.1877,65.0421,18.5579,65.6747,13.7778,67.5849,11.2150,68.1610,7.5359,67.6326,5.1034,66.7569,2.2150,66.1991,0.2476,66.2511,355.2714,64.4260,350.1006,62.9385,344.4302,62.4847,339.3002,60.4931,333.4702,58.8851,331.1327,59.4605,330.9382,61.9623,340.6152,65.1092,343.2879,66.7647,343.3581,70.1643,341.1773,70.8790,336.6449,73.1299,333.5813,75.1391,324.6186,76.5981,322.6315,75.9427,319.0891,73.7015,314.8850,71.0639,315.6348,68.7048,315.4588,66.8973,313.0831,64.8622,312.0635,62.5880,314.0142,60.2312,313.6305,59.4861,311.8591,59.3915,309.8048,58.5233,310.2041,57.1819,306.9144,55.9804,304.6804,54.7702,305.2319,53.7273,307.7332,52.9357,308.4628,52.2243,308.0011,51.7205,306.5029,51.7495,304.1552,50.3598,302.5798,48.5193,302.1408,47.1988,300.7148,46.6004,299.0278,47.0595,297.2437,46.8711,295.7553,46.0584,294.7485,44.4314,292.7755,41.4764,288.6933,39.1997,287.4471,37.8510,287.5118,36.7221,289.2538,36.0606,290.9985,36.0011,292.6594,35.2362,292.3950,34.1260,291.2070,33.2023,288.4240,33.0160,287.0090,32.3231,286.5059,31.2666,287.0896,29.6223,286.5820,27.3683,285.1771,25.5062,283.1010,23.9449,281.0469,22.8010,279.4373,21.4068,279.1821,19.6473,279.7551,18.2109,280.9496,17.7421,282.6220,17.1833,283.2966,16.1546,283.0965,15.0372,281.5283,14.0053,280.0909,12.9151,278.6961,11.5975,277.0707,10.5385,275.4225,9.5688,273.4622,8.5487,270.9916,7.3738,269.3157,5.7621,268.8705,4.4507,269.5389,3.1987,271.0927,2.6855,273.2811,2.5608,275.4739,3.3006,278.1912,5.0780,281.9090,8.5271,284.1697,11.3576,286.2251,14.0132,286.3233,15.1915,286.1158,15.5100,285.7599,16.6912,286.7631,18.2914,287.1951,19.3787,286.9849,20.8446,289.3257,22.5391,291.9464,25.4127,292.4286,28.5332,293.4593,30.5996,297.2230,32.4772,299.7133,35.0595,303.4745,35.0807,304.1266,34.0245,303.6819,30.3270,300.8895,28.8322,296.8912,28.3993,294.9144,27.6937,294.5657,26.6140,296.1041,25.9037,297.3739,25.6845,297.8246,24.4710,295.9400,22.7956,293.0649,21.4866,291.1229,20.8588,290.3240,19.7277,290.6364,18.7692,292.0849,18.3219,293.1902,17.8198,293.1968,16.4933,292.2117,15.0311,290.2679,13.8513,288.7569,12.5900,288.6076,10.9218,288.7328,9.7665,287.2839,8.8429,285.5820,8.1189,284.2910,7.0396,283.2366,5.8411,282.9327,3.8283,282.5522,3.2275,280.8435,2.7268,279.3674,2.2532,278.3224,1.3453,278.2314,0.7429,279.1609,1.5654,279.5029,0.9574,279.8970,-0.2562,281.6814,-0.8823,283.4665,-1.5073,284.0658,-2.4642,282.9599,-3.4894,281.1068,-3.6991,279.0490,-4.0172,278.1079,-5.1256,278.2582,-6.7990,279.0976,-9.4057,278.6578,-11.6460,277.3170,-14.1578,276.1600,-16.6976,275.8960,-19.1108,275.5302,-21.8370,274.9452,-24.2398,272.9984,-26.4299,270.0613,-28.0668,268.2229,-29.8114,268.5228,-30.2623,267.9381,-32.1320,267.1568,-33.2373,265.0719,-33.6050,262.2933,-32.3327,261.2061,-30.0066,261.2532,-27.8653,261.7517,-26.3868,262.0161,-24.8538,263.5809,-22.8788,265.0658,-21.1691,265.8074,-19.4150,266.6842,-18.7916,268.0278,-18.7995,268.7521,-17.4910,268.3084,-15.3906,266.9707,-13.3787,264.9872,-12.4603,262.4372,-12.2113,260.5499,-13.2122,258.4769,-14.8956,256.1174,-16.7507,254.4650,-18.3043,253.6343,-20.3552,254.1778,-21.5124,254.1778,-21.5124,254.1778,-21.5124],
    +		["#396bad",257.4284,-35.0782,258.7567,-34.6776,259.6113,-33.6806,259.9750,-32.7946,260.9776,-32.5946,263.4189,-33.7211,265.2841,-35.1551,267.0684,-37.0106,266.5317,-38.4758,264.8234,-39.1661,263.0403,-38.8168,260.6629,-38.1140,258.3247,-38.4735,255.5110,-38.1878,254.0470,-37.3602,253.5295,-36.1222,253.7763,-35.4603,254.7476,-35.1875,256.2938,-35.0264,257.4284,-35.0782,257.4284,-35.0782,257.4284,-35.0782],
    +		["#396bad",254.4847,-39.5230,255.2762,-39.2671,257.3657,-39.9243,258.7042,-41.3823,258.3749,-43.2682,257.0644,-43.3665,254.3218,-40.8215,254.6242,-40.4285,254.4847,-39.5230,254.4847,-39.5230,254.4847,-39.5230],
    +		["#396bad",247.9505,-43.8132,247.7714,-42.7233,248.2105,-41.7101,249.7280,-40.8373,248.8720,-37.3180,247.1054,-36.2799,245.6020,-36.7984,243.1195,-35.6949,242.2253,-36.0340,241.9943,-38.6050,241.6221,-40.6326,239.8163,-43.0489,238.8334,-44.6762,238.5119,-46.8016,237.7857,-49.0401,236.8718,-50.6695,237.0713,-52.3828,238.9693,-53.2821,241.4236,-52.3398,244.7950,-51.3601,248.9798,-51.6343,253.4436,-51.0624,257.8767,-50.6966,260.9423,-50.2581,261.6140,-49.2459,260.7624,-46.4869,260.5888,-45.3986,259.8614,-44.8904,257.7870,-44.9484,256.1962,-45.7995,256.3613,-48.5425,253.5419,-49.9573,249.8040,-49.5087,248.3220,-49.4156,245.7227,-48.7635,244.6261,-48.0299,245.6396,-46.7119,247.9407,-46.1849,251.5170,-45.7312,252.8143,-45.1508,251.9638,-44.5626,249.5206,-44.4962,247.9505,-43.8132,247.9505,-43.8132,247.9505,-43.8132],
    +		["#396bad",231.8281,-52.4833,233.5614,-52.5365,234.4273,-52.7145,236.0044,-53.8647,236.5304,-55.8352,237.3389,-57.1286,230.3741,-58.8315,226.4955,-59.1351,221.0867,-60.4628,220.9820,-59.2769,221.7242,-59.0271,223.2728,-57.1869,226.5641,-55.0286,228.1040,-54.5097,229.9674,-52.9922,231.8281,-52.4833,231.8281,-52.4833,231.8281,-52.4833],
    +		["#396bad",191.9931,-60.9308,195.0383,-60.9157,197.2513,-61.5730,197.6589,-63.3081,193.3910,-65.2609,190.6198,-65.3102,189.1651,-65.7997,187.9019,-65.8296,187.4866,-64.7787,188.0292,-64.2788,187.2718,-63.6037,188.0759,-62.2722,190.6559,-61.1093,191.9931,-60.9308,191.9931,-60.9308],
    +		["#396bad",132.7337,-37.5805,133.0861,-38.9333,132.3284,-39.7468,129.7569,-39.4258,127.1207,-38.0660,124.9328,-36.1447,124.8899,-34.6079,124.7196,-32.9119,125.9376,-32.1253,127.3543,-32.6954,128.6915,-33.7875,129.6263,-35.1366,131.7847,-36.5415,132.7337,-37.5805,132.7337,-37.5805],
    +		["#396bad",121.1825,-39.3994,119.9070,-39.0295,119.2840,-37.6001,119.3245,-36.1550,117.3621,-34.3430,116.3134,-31.1424,115.5167,-28.5014,115.3112,-26.0891,115.8367,-24.7060,117.0334,-24.5218,117.8897,-25.7087,118.5145,-28.1509,119.6664,-31.5362,121.7083,-34.8713,122.3366,-37.4519,122.1419,-39.0037,121.1825,-39.3994,121.1825,-39.3994],
    +		["#396bad",305.9982,40.1858,309.8895,39.3472,311.2759,37.2754,311.2100,35.4356,310.1900,34.7837,308.6971,34.9849,307.5461,36.5500,305.0697,37.7876,304.5142,39.0978,305.1288,40.0209,305.9982,40.1858]
    +	]
    +}
    diff --git a/html/allsky/virtualsky/lang/ar.json b/html/allsky/virtualsky/lang/ar.json
    new file mode 100755
    index 000000000..4c1de269e
    --- /dev/null
    +++ b/html/allsky/virtualsky/lang/ar.json
    @@ -0,0 +1,194 @@
    +{
    + "title": "&#1575;&#1604;&#1587;&#1605;&#1575;&#1569; &#1575;&#1604;&#1575;&#1601;&#1578;&#1585;&#1575;&#1590;&#1610;&#1577;",
    + "language": {
    +  "code": "ar",
    +  "name": "&#1575;&#1604;&#1593;&#1585;&#1576;&#1610;&#1577;",
    +  "alignment": "right",
    +  "translator": "kutaibaa-akraa"
    + },
    + "position": "&#1575;&#1604;&#1591;&#1608;&#1604;  &  &#1575;&#1604;&#1593;&#1585;&#1590;",
    + "constellations": {
    +  "And": "&#1575;&#1604;&#1605;&#1585;&#1571;&#1577; &#1575;&#1604;&#1605;&#1587;&#1604;&#1587;&#1604;&#1577;",
    +  "Ant": "&#1605;&#1601;&#1585;&#1594;&#1577; &#1575;&#1604;&#1607;&#1608;&#1575;&#1569;",
    +  "Aps": "&#1591;&#1575;&#1574;&#1585; &#1575;&#1604;&#1601;&#1585;&#1583;&#1608;&#1587;",
    +  "Aqr": "&#1575;&#1604;&#1583;&#1604;&#1608;",
    +  "Aql": "&#1575;&#1604;&#1593;&#1602;&#1575;&#1576;",
    +  "Ara": "&#1575;&#1604;&#1605;&#1580;&#1605;&#1585;&#1577;",
    +  "Ari": "&#1575;&#1604;&#1581;&#1605;&#1604;",
    +  "Aur": "&#1605;&#1605;&#1587;&#1603; &#1575;&#1604;&#1571;&#1593;&#1606;&#1577;",
    +  "Boo": "&#1575;&#1604;&#1593;&#1608;&#1575;&#1569;",
    +  "Cae": " &#1575;&#1604;&#1606;&#1602;&#1575;&#1588;",
    +  "Cam": "&#1575;&#1604;&#1586;&#1585;&#1575;&#1601;&#1577;",
    +  "Cnc": "&#1575;&#1604;&#1587;&#1585;&#1591;&#1575;&#1606;",
    +  "CVn": "&#1575;&#1604;&#1587;&#1604;&#1608;&#1602;&#1610;&#1610;&#1606;",
    +  "CMa": "&#1575;&#1604;&#1603;&#1604;&#1576; &#1575;&#1604;&#1571;&#1603;&#1576;&#1585;",
    +  "CMi": "&#1575;&#1604;&#1603;&#1604;&#1576; &#1575;&#1604;&#1571;&#1589;&#1594;&#1585;",
    +  "Cap": "&#1575;&#1604;&#1580;&#1583;&#1610;",
    +  "Car": "&#1575;&#1604;&#1580;&#1572;&#1580;&#1572;",
    +  "Cas": "&#1584;&#1575;&#1578; &#1575;&#1604;&#1603;&#1585;&#1587;&#1610;",
    +  "Cen": "&#1602;&#1606;&#1591;&#1608;&#1585;&#1587;",
    +  "Cep": "&#1602;&#1610;&#1601;&#1575;&#1608;&#1587;",
    +  "Cet": "&#1602;&#1610;&#1591;&#1587;",
    +  "Cha": "&#1575;&#1604;&#1586;&#1585;&#1575;&#1601;&#1577;",
    +  "Cir": "&#1575;&#1604;&#1576;&#1610;&#1603;&#1575;&#1585;",
    +  "Col": "&#1575;&#1604;&#1581;&#1605;&#1575;&#1605;&#1577;",
    +  "Com": "&#1590;&#1601;&#1610;&#1585;&#1577; &#1576;&#1585;&#1606;&#1610;&#1587;",
    +  "CrA": "&#1575;&#1604;&#1573;&#1603;&#1604;&#1610;&#1604; &#1575;&#1604;&#1580;&#1606;&#1608;&#1576;&#1610;",
    +  "CrB": "&#1575;&#1604;&#1573;&#1603;&#1604;&#1610;&#1604; &#1575;&#1604;&#1588;&#1605;&#1575;&#1604;&#1610;",
    +  "Crv": "&#1575;&#1604;&#1594;&#1585;&#1575;&#1576;",
    +  "Crt": "&#1575;&#1604;&#1602;&#1589;&#1593;&#1577;",
    +  "Cru": "&#1575;&#1604;&#1589;&#1604;&#1610;&#1576; &#1575;&#1604;&#1580;&#1606;&#1608;&#1576;&#1610;",
    +  "Cyg": "&#1575;&#1604;&#1583;&#1580;&#1575;&#1580;&#1577;",
    +  "Del": "&#1575;&#1604;&#1583;&#1604;&#1601;&#1610;&#1606;",
    +  "Dor": "&#1571;&#1576;&#1610; &#1587;&#1610;&#1601;",
    +  "Dra": "&#1575;&#1604;&#1578;&#1606;&#1610;&#1606;",
    +  "Equ": " &#1575;&#1604;&#1601;&#1585;&#1587;",
    +  "Eri": "&#1575;&#1604;&#1606;&#1607;&#1585;",
    +  "For": "&#1575;&#1604;&#1603;&#1608;&#1585;",
    +  "Gem": "&#1575;&#1604;&#1578;&#1608;&#1571;&#1605;&#1610;&#1606;",
    +  "Gru": "&#1575;&#1604;&#1603;&#1585;&#1603;&#1610;",
    +  "Her": "&#1575;&#1604;&#1580;&#1575;&#1579;&#1610;",
    +  "Hor": "&#1575;&#1604;&#1587;&#1575;&#1593;&#1577;",
    +  "Hya": "&#1575;&#1604;&#1588;&#1580;&#1575;&#1593;",
    +  "Hyi": "&#1581;&#1610;&#1577; &#1575;&#1604;&#1605;&#1575;&#1569;",
    +  "Ind": "&#1575;&#1604;&#1607;&#1606;&#1583;&#1610;",
    +  "Lac": "&#1575;&#1604;&#1593;&#1592;&#1575;&#1569;&#1577;",
    +  "Leo": "&#1575;&#1604;&#1571;&#1587;&#1583;",
    +  "LMi": " &#1575;&#1604;&#1571;&#1590;&#1594;&#1585;",
    +  "Lep": "&#1575;&#1604;&#1571;&#1585;&#1606;&#1576;",
    +  "Lib": "&#1575;&#1604;&#1605;&#1610;&#1586;&#1575;&#1606;",
    +  "Lup": "&#1575;&#1604;&#1587;&#1576;&#1593;",
    +  "Lyn": "&#1575;&#1604;&#1608;&#1588;&#1602;",
    +  "Lyr": "&#1575;&#1604;&#1604;&#1608;&#1585;&#1575;",
    +  "Men": "&#1575;&#1604;&#1580;&#1576;&#1604;",
    +  "Mic": "&#1575;&#1604;&#1605;&#1580;&#1607;&#1585;",
    +  "Mon": "&#1608;&#1581;&#1610;&#1583; &#1575;&#1604;&#1602;&#1585;&#1606;",
    +  "Mus": "&#1575;&#1604;&#1584;&#1576;&#1575;&#1576;&#1577;",
    +  "Nor": "&#1605;&#1585;&#1576;&#1593; &#1575;&#1604;&#1606;&#1580;&#1575;&#1585;",
    +  "Oct": "&#1575;&#1604;&#1579;&#1605;&#1606;",
    +  "Oph": "&#1575;&#1604;&#1581;&#1608;&#1575;&#1569;",
    +  "Ori": "&#1575;&#1604;&#1580;&#1576;&#1617;&#1575;&#1585;",
    +  "Pav": "&#1575;&#1604;&#1591;&#1575;&#1608;&#1608;&#1587;",
    +  "Peg": "&#1575;&#1604;&#1601;&#1585;&#1587; &#1575;&#1604;&#1605;&#1580;&#1606;&#1581;",
    +  "Per": "&#1576;&#1585;&#1588;&#1575;&#1608;&#1587;",
    +  "Phe": "&#1575;&#1604;&#1593;&#1606;&#1602;&#1575;&#1569;",
    +  "Pic": "&#1575;&#1604;&#1585;&#1587;&#1575;&#1605;",
    +  "Psc": "&#1575;&#1604;&#1581;&#1608;&#1578;",
    +  "PsA": "&#1575;&#1604;&#1581;&#1608;&#1578; &#1575;&#1604;&#1580;&#1606;&#1608;&#1576;&#1610;",
    +  "Pup": "&#1575;&#1604;&#1603;&#1608;&#1579;&#1604;",
    +  "Pyx": "&#1575;&#1604;&#1576;&#1608;&#1589;&#1604;&#1577;",
    +  "Ret": "&#1575;&#1604;&#1588;&#1576;&#1603;&#1577;",
    +  "Sge": "&#1575;&#1604;&#1587;&#1607;&#1605;",
    +  "Sgr": "&#1575;&#1604;&#1585;&#1575;&#1605;&#1610;",
    +  "Sco": "&#1575;&#1604;&#1593;&#1602;&#1585;&#1576;",
    +  "Scl": "&#1575;&#1604;&#1606;&#1581;&#1575;&#1578;",
    +  "Sct": "&#1575;&#1604;&#1578;&#1585;&#1587;",
    +  "Ser": "&#1575;&#1604;&#1581;&#1610;&#1577;",
    +  "Sex": "&#1570;&#1604;&#1577; &#1575;&#1604;&#1587;&#1583;&#1587;",
    +  "Tau": "&#1575;&#1604;&#1579;&#1608;&#1585;",
    +  "Tel": "&#1575;&#1604;&#1578;&#1604;&#1587;&#1603;&#1608;&#1576;",
    +  "Tri": "&#1575;&#1604;&#1605;&#1579;&#1604;&#1579;",
    +  "TrA": "&#1575;&#1604;&#1605;&#1579;&#1604;&#1579; &#1575;&#1604;&#1580;&#1606;&#1608;&#1576;&#1610;",
    +  "Tuc": "&#1575;&#1604;&#1591;&#1608;&#1602;&#1575;&#1606;",
    +  "UMa": "&#1575;&#1604;&#1583;&#1576; &#1575;&#1604;&#1571;&#1603;&#1576;&#1585;",
    +  "UMi": "&#1575;&#1604;&#1583;&#1576; &#1575;&#1604;&#1571;&#1589;&#1594;&#1585;",
    +  "Vel": "&#1575;&#1604;&#1588;&#1585;&#1575;&#1593;",
    +  "Vir": "&#1575;&#1604;&#1593;&#1584;&#1585;&#1575;&#1569;",
    +  "Vol": "&#1575;&#1604;&#1587;&#1605;&#1603;&#1577; &#1575;&#1604;&#1591;&#1575;&#1574;&#1585;&#1577;",
    +  "Vul": "&#1575;&#1604;&#1579;&#1593;&#1604;&#1576;"
    + },
    + "planets": {
    +  "Me": "&#1593;&#1591;&#1575;&#1585;&#1583;",
    +  "V": "&#1575;&#1604;&#1586;&#1607;&#1585;&#1577;",
    +  "Ma": "&#1575;&#1604;&#1605;&#1585;&#1610;&#1582;",
    +  "J": "&#1575;&#1604;&#1605;&#1588;&#1578;&#1585;&#1610;",
    +  "S": "&#1586;&#1581;&#1604;",
    +  "U": "&#1571;&#1608;&#1585;&#1575;&#1606;&#1608;&#1587;",
    +  "N": "&#1606;&#1576;&#1578;&#1608;&#1606;"
    + },
    + "sun": "&#1575;&#1604;&#1588;&#1605;&#1587;",
    + "moon": "&#1575;&#1604;&#1602;&#1605;&#1585;",
    + "N": "&#1588;&#1605;&#1575;&#1604;",
    + "E": "&#1588;&#1585;&#1602;",
    + "S": "&#1580;&#1606;&#1608;&#1576;",
    + "W": "&#1594;&#1585;&#1576;",
    + "keyboard": "&#1575;&#1582;&#1578;&#1589;&#1575;&#1585;&#1575;&#1578; &#1575;&#1604;&#1605;&#1601;&#1575;&#1578;&#1610;&#1581;:",
    + "fast": "&#1578;&#1587;&#1585;&#1610;&#1593; &#1575;&#1604;&#1586;&#1605;&#1606;",
    + "stop": "&#1573;&#1610;&#1602;&#1575;&#1601; &#1575;&#1604;&#1586;&#1605;&#1606;",
    + "slow": "&#1573;&#1576;&#1591;&#1575;&#1569; &#1575;&#1604;&#1586;&#1605;&#1606;",
    + "reset": "&#1575;&#1604;&#1586;&#1605;&#1606; &#1575;&#1604;&#1581;&#1575;&#1604;&#1610;",
    + "cardinal": "&#1575;&#1604;&#1573;&#1578;&#1580;&#1575;&#1607;&#1575;&#1578;",
    + "stars": "&#1575;&#1604;&#1606;&#1580;&#1608;&#1605;",
    + "starlabels": "&#1571;&#1587;&#1605;&#1575;&#1569; &#1575;&#1604;&#1606;&#1580;&#1608;&#1605;",
    + "neg": "&#1593;&#1603;&#1587; &#1575;&#1604;&#1571;&#1604;&#1608;&#1575;&#1606;",
    + "atmos": "&#1575;&#1604;&#1594;&#1604;&#1575;&#1601; &#1575;&#1604;&#1580;&#1608;&#1610;",
    + "ground": "&#1575;&#1604;&#1571;&#1601;&#1602;",
    + "az": " Az/El &#1575;&#1604;&#1573;&#1581;&#1583;&#1575;&#1579;&#1610;&#1575;&#1578;  &#1575;&#1604;&#1587;&#1605;&#1578;&#1610;&#1577;",
    + "eq": " Ra/Dec &#1575;&#1604;&#1573;&#1581;&#1583;&#1575;&#1579;&#1610;&#1575;&#1578; &#1575;&#1604;&#1575;&#1587;&#1578;&#1608;&#1575;&#1574;&#1610;&#1577;",
    + "gal": "&#1575;&#1604;&#1573;&#1581;&#1583;&#1575;&#1579;&#1610;&#1575;&#1578; &#1575;&#1604;&#1605;&#1580;&#1585;&#1610;&#1577;",
    + "galaxy": " &#1575;&#1604;&#1605;&#1587;&#1578;&#1608;&#1610; &#1575;&#1604;&#1605;&#1580;&#1585;&#1610;",
    + "ec": " &#1583;&#1575;&#1574;&#1585;&#1577; &#1575;&#1604;&#1603;&#1587;&#1608;&#1601;",
    + "meridian": " &#1582;&#1591; &#1605;&#1606;&#1578;&#1589;&#1601; &#1575;&#1604;&#1606;&#1607;&#1575;&#1585;",
    + "con": " &#1582;&#1591;&#1608;&#1591; &#1575;&#1604;&#1603;&#1608;&#1603;&#1576;&#1575;&#1578;",
    + "conbound": " &#1581;&#1583;&#1608;&#1583; &#1575;&#1604;&#1603;&#1608;&#1603;&#1576;&#1575;&#1578;",
    + "names": " &#1571;&#1587;&#1605;&#1575;&#1569; &#1575;&#1604;&#1603;&#1608;&#1603;&#1576;&#1575;&#1578;",
    + "sol": " &#1575;&#1604;&#1603;&#1608;&#1575;&#1603;&#1576;/&#1575;&#1604;&#1588;&#1605;&#1587;/&#1575;&#1604;&#1602;&#1605;&#1585;",
    + "sollabels": " &#1571;&#1587;&#1605;&#1575;&#1569; &#1575;&#1604;&#1603;&#1608;&#1575;&#1603;&#1576;/&#1575;&#1604;&#1588;&#1605;&#1587;/&#1575;&#1604;&#1602;&#1605;&#1585; ",
    + "orbits": "&#1605;&#1583;&#1575;&#1585;&#1575;&#1578; &#1575;&#1604;&#1603;&#1608;&#1575;&#1603;&#1576;",
    + "projection": "&#1578;&#1594;&#1610;&#1610;&#1585; &#1591;&#1585;&#1610;&#1602;&#1577; &#1575;&#1604;&#1573;&#1587;&#1602;&#1575;&#1591;",
    + "meteorshowers": "&#1586;&#1582;&#1575;&#1578; &#1575;&#1604;&#1588;&#1607;&#1576;",
    + "addday": "&#1578;&#1602;&#1583;&#1605; &#1610;&#1608;&#1605;&#1575;&#1611; &#1608;&#1575;&#1581;&#1583;&#1575;&#1611;",
    + "subtractday": "&#1578;&#1571;&#1582;&#1585; &#1610;&#1608;&#1605;&#1575;&#1611; &#1608;&#1575;&#1581;&#1583;&#1575;&#1611;",
    + "addweek": "&#1578;&#1602;&#1583;&#1605; &#1571;&#1587;&#1576;&#1608;&#1593;&#1575;&#1611; &#1608;&#1575;&#1581;&#1583;&#1575;&#1611;",
    + "subtractweek": "&#1578;&#1571;&#1582;&#1585; &#1571;&#1587;&#1576;&#1608;&#1593;&#1575;&#1611; &#1608;&#1575;&#1581;&#1583;&#1575;&#1611;",
    + "azleft": "&#1575;&#1584;&#1607;&#1576; &#1610;&#1587;&#1575;&#1585;&#1575;&#1611;",
    + "azright": "&#1575;&#1584;&#1607;&#1576; &#1610;&#1605;&#1610;&#1606;&#1575;&#1611;",
    + "magup": "&#1586;&#1610;&#1575;&#1583;&#1577; &#1593;&#1583;&#1583; &#1575;&#1604;&#1606;&#1580;&#1608;&#1605; &#1575;&#1604;&#1605;&#1585;&#1574;&#1610;&#1577;",
    + "magdown": "&#1578;&#1582;&#1601;&#1610;&#1590; &#1593;&#1583;&#1583; &#1575;&#1604;&#1606;&#1580;&#1608;&#1605; &#1575;&#1604;&#1605;&#1585;&#1574;&#1610;&#1577;",
    + "left": "&#8592;",
    + "right": "&#8594;",
    + "up": "&#8593;",
    + "down": "&#8595;",
    + "power": "Powered by LCOGT",
    + "date": "&#1575;&#1604;&#1578;&#1575;&#1585;&#1610;&#1582;",
    + "datechange": "&#1578;&#1594;&#1610;&#1610;&#1585; &#1575;&#1604;&#1578;&#1575;&#1585;&#1610;&#1582;",
    + "close": "&#1571;&#1594;&#1604;&#1602;",
    + "positionchange": "&#1578;&#1594;&#1610;&#1610;&#1585; &#1575;&#1604;&#1605;&#1608;&#1602;&#1593;",
    + "starnames": {
    +  "7588": "&#1570;&#1582;&#1585; &#1575;&#1604;&#1606;&#1607;&#1585;",
    +  "11767": "&#1575;&#1604;&#1580;&#1583;&#1610;",
    +  "21421": "&#1575;&#1604;&#1583;&#1576;&#1585;&#1575;&#1606;",
    +  "24436": "&#1585;&#1580;&#1604; &#1575;&#1604;&#1580;&#1576;&#1575;&#1585;",
    +  "24608": "&#1575;&#1604;&#1593;&#1610;&#1608;&#1602;",
    +  "27989": "&#1605;&#1606;&#1603;&#1576; &#1575;&#1604;&#1580;&#1608;&#1586;&#1575;&#1569;",
    +  "30438": "&#1587;&#1607;&#1610;&#1604;",
    +  "32349": "&#1575;&#1604;&#1588;&#1593;&#1585;&#1609; &#1575;&#1604;&#1610;&#1605;&#1575;&#1606;&#1610;&#1577;",
    +  "33579": "&#1575;&#1604;&#1593;&#1584;&#1575;&#1585;&#1609;",
    +  "37279": "&#1575;&#1604;&#1588;&#1593;&#1585;&#1609; &#1575;&#1604;&#1588;&#1575;&#1605;&#1610;&#1577;",
    +  "37826": "&#1585;&#1571;&#1587; &#1575;&#1604;&#1578;&#1608;&#1571;&#1605; &#1575;&#1604;&#1605;&#1572;&#1582;&#1585;",
    +  "49669": "&#1575;&#1604;&#1605;&#1604;&#1610;&#1603;",
    +  "62434": "&#1605;&#1610;&#1605;&#1608;&#1587;&#1575;",
    +  "65378": "&#1575;&#1604;&#1605;&#1574;&#1586;&#1585;",
    +  "65474": "&#1575;&#1604;&#1587;&#1605;&#1575;&#1603; &#1575;&#1604;&#1571;&#1593;&#1586;&#1604;",
    +  "68702": "&#1581;&#1590;&#1575;&#1585;",
    +  "69673": "&#1575;&#1604;&#1587;&#1605;&#1575;&#1603; &#1575;&#1604;&#1585;&#1575;&#1605;&#1581;",
    +  "71683": "&#1571;&#1604;&#1601;&#1575; &#1602;&#1606;&#1591;&#1608;&#1585;&#1587;",
    +  "80763": "&#1602;&#1604;&#1576; &#1575;&#1604;&#1593;&#1602;&#1585;&#1576;",
    +  "85927": "&#1575;&#1604;&#1588;&#1608;&#1604;&#1577;",
    +  "91262": "&#1575;&#1604;&#1606;&#1587;&#1585; &#1575;&#1604;&#1608;&#1575;&#1602;&#1593;",
    +  "97649": "&#1575;&#1604;&#1606;&#1587;&#1585; &#1575;&#1604;&#1591;&#1575;&#1574;&#1585;",
    +  "102098": "&#1584;&#1606;&#1576; &#1575;&#1604;&#1583;&#1580;&#1575;&#1580;&#1577;",
    +  "113368": "&#1601;&#1605; &#1575;&#1604;&#1581;&#1608;&#1578;"
    + },
    + "projections": {
    +  "polar": "&#1602;&#1591;&#1576;&#1610;",
    +  "fisheye": "&#1593;&#1610;&#1606; &#1575;&#1604;&#1587;&#1605;&#1603;&#1577;",
    +  "ortho": "&#1571;&#1608;&#1585;&#1579;&#1608;&#1594;&#1585;&#1575;&#1601;&#1610;",
    +  "stereo": "&#1587;&#1578;&#1610;&#1585;&#1610;&#1608;&#1594;&#1585;&#1575;&#1601;&#1610;",
    +  "lambert": "&#1604;&#1575;&#1605;&#1576;&#1585;&#1578;",
    +  "gnomic": "&#1594;&#1606;&#1608;&#1605;&#1608;&#1606;&#1610;",
    +  "equirectangular": "",
    +  "mollweide": "&#1605;&#1608;&#1604;&#1608;&#1610;&#1583;",
    +  "planechart": "&#1582;&#1575;&#1585;&#1591;&#1577; &#1605;&#1587;&#1578;&#1608;&#1610;&#1577;"
    + }
    +}
    diff --git a/html/allsky/virtualsky/lang/cs.json b/html/allsky/virtualsky/lang/cs.json
    new file mode 100755
    index 000000000..3146d5beb
    --- /dev/null
    +++ b/html/allsky/virtualsky/lang/cs.json
    @@ -0,0 +1,121 @@
    +{
    +	"title": "VirtualSky",
    +	"language": {
    +		"code": "cs",
    +		"name": "Čeština",
    +		"alignment": "left",
    +		"translator": "Hinogary"
    +	},
    +	"position":"zeměpisná šířka a délka",
    +	"N": "S",
    +	"W": "Z",
    +	"E": "V",
    +	"S": "J",
    +	"sun": "Slunce",
    +	"moon": "Měsíc",
    +	"planets": {
    +		"Me": "Merkur",
    +		"V": "Venuše",
    +		"Ma": "Mars",
    +		"J": "Jupiter",
    +		"S": "Saturn",
    +		"U": "Uran",
    +		"N": "Neptun"
    +	},
    +	"constellations": {
    +		"And": "Andromeda",
    +		"Ant": "V&#253;v&#283;va",
    +		"Aps": "Rajka",
    +		"Aqr": "Vodn&#225;&#345;",
    +		"Aql": "Orel",
    +		"Ara": "Olt&#225;&#345;",
    +		"Ari": "Beran",
    +		"Aur": "Vozka",
    +		"Boo": "Past&#253;&#345;",
    +		"Cae": "Rydlo",
    +		"Cam": "&#381;irafa",
    +		"Cnc": "Rak",
    +		"CVn": "Hon&#237;c&#237; psi",
    +		"CMa": "Velk&#253; pes",
    +		"CMi": "Mal&#253; pes",
    +		"Cap": "Kozoroh",
    +		"Car": "Lodn&#237; k&#253;l",
    +		"Cas": "Kasiopeja",
    +		"Cen": "Kentaur",
    +		"Cep": "Kefeus",
    +		"Cet": "Velryba",
    +		"Cha": "Chamaeleon",
    +		"Cir": "Kru&#382;&#237;tko",
    +		"Col": "Holubice",
    +		"Com": "Vlas Bereni&#269;in",
    +		"CrA": "Ji&#382;n&#237; koruna",
    +		"CrB": "Severn&#237; koruna",
    +		"Crv": "Havran",
    +		"Crt": "Poh&#225;r",
    +		"Cru": "Ji&#382;n&#237; k&#345;&#237;&#382;",
    +		"Cyg": "Labu&#357;",
    +		"Del": "Delf&#237;n",
    +		"Dor": "Me&#269;oun",
    +		"Dra": "Drak",
    +		"Equ": "Kon&#237;&#269;ek",
    +		"Eri": "&#344;eka Eridanus",
    +		"For": "Pec",
    +		"Gem": "Bl&#237;&#382;enci",
    +		"Gru": "Je&#345;&#225;b",
    +		"Her": "Herkules",
    +		"Hor": "Hodiny",
    +		"Hya": "Hydra",
    +		"Hyi": "Mal&#253; vodn&#237; hrad",
    +		"Ind": "Indi&#225;n",
    +		"Lac": "Je&#353;t&#283;rka",
    +		"Leo": "Lev",
    +		"LMi": "Mal&#253; lev",
    +		"Lep": "Zaj&#237;c",
    +		"Lib": "V&#225;hy",
    +		"Lup": "Vlk",
    +		"Lyn": "Rys",
    +		"Lyr": "Lyra",
    +		"Men": "Tabulov&#225; hora",
    +		"Mic": "Mikroskop",
    +		"Mon": "Jednoro&#382;ec",
    +		"Mus": "Moucha",
    +		"Nor": "Prav&#237;tko",
    +		"Oct": "Oktant",
    +		"Oph": "Hladono&#353;",
    +		"Ori": "Orion",
    +		"Pav": "P&#225;v",
    +		"Peg": "Pegas",
    +		"Per": "Perseus",
    +		"Phe": "F&#233;nix",
    +		"Pic": "Mal&#237;&#345;",
    +		"Psc": "Ryby",
    +		"PsA": "Ji&#382;n&#237; ryba",
    +		"Pup": "Lodn&#237; z&#225;&#271;",
    +		"Pyx": "Kompas",
    +		"Ret": "M&#345;&#237;&#382;ka",
    +		"Sge": "&#352;&#237;p",
    +		"Sgr": "St&#345;elec",
    +		"Sco": "&#352;t&#237;r",
    +		"Scl": "Socha&#345;",
    +		"Sct": "&#352;t&#237;t",
    +		"Ser": "Had",
    +		"Sex": "Sextant",
    +		"Tau": "B&#253;k",
    +		"Tel": "Dalekohled",
    +		"Tri": "Troj&#250;heln&#237;k",
    +		"TrA": "Ji&#382;n&#237; troj&#250;heln&#237;k",
    +		"Tuc": "Tukan",
    +		"UMa": "Velk&#225; medv&#283;dice",
    +		"UMi": "Mal&#253; medv&#283;d",
    +		"Vel": "Plachty",
    +		"Vir": "Panna",
    +		"Vol": "Letaj&#237;c&#237; ryba",
    +		"Vul": "Li&#353;ka"
    +	},
    +	"starnames":{
    +		"7588":"Achernar","11767":"Polaris","21421":"Aldebaran","24436":"Rigel","24608":"Capella","27989":"Betelgeuse",
    +		"30438":"Canopus","32349":"Sirius","33579":"Adara","37279":"Procyon","37826":"Pollux","49669":"Regulus","62434":"Mimosa",
    +		"65378":"Mizar","65474":"Spica","68702":"Hadar","69673":"Arcturus","71683":"Alpha Centauri A","80763":"Antares","85927":"Shaula",
    +		"91262":"Vega","97649":"Altair","102098":"Deneb","113368":"Fomalhaut"
    +	}
    +}
    \ No newline at end of file
    diff --git a/html/allsky/virtualsky/lang/de.json b/html/allsky/virtualsky/lang/de.json
    new file mode 100644
    index 000000000..a023b5f94
    --- /dev/null
    +++ b/html/allsky/virtualsky/lang/de.json
    @@ -0,0 +1,174 @@
    +{
    +	"title": "VirtualSky",
    +	"language": {
    +		"code": "de",
    +		"name": "Deutsch",
    +		"alignment": "left",
    +		"translator": "Gerd Heringslake"
    +	},
    +	"constellations": {
    +		"And": "Andromeda",
    +		"Ant": "Luftpumpe",
    +		"Aps": "Paradiesvogel",
    +		"Aqr": "Wassermann",
    +		"Aql": "Adler",
    +		"Ara": "Altar",
    +		"Ari": "Widder",
    +		"Aur": "Fuhrmann",
    +		"Boo": "B&auml;renh&uuml;ter",
    +		"Cae": "Grabstichel",
    +		"Cam": "Giraffe",
    +		"Cnc": "Krebs",
    +		"CVn": "Jagdhunde",
    +		"CMa": "Gro&szlig;er Hund",
    +		"CMi": "Kleiner Hund",
    +		"Cap": "Steinbock",
    +		"Car": "Schiffskiel",
    +		"Cas": "Kassiopeia",
    +		"Cen": "Zentaur",
    +		"Cep": "Kepheus",
    +		"Cet": "Walfisch",
    +		"Cha": "Chamaeleon",
    +		"Cir": "Zirkel",
    +		"Col": "Taube",
    +		"Com": "Haar der Berenike",
    +		"CrA": "S&uuml;dliche Krone",
    +		"CrB": "N&ouml;rdliche Krone",
    +		"Crv": "Rabe",
    +		"Crt": "Becher",
    +		"Cru": "Kreuz des S&uuml;dens",
    +		"Cyg": "Schwan",
    +		"Del": "Delphin",
    +		"Dor": "Schwertfisch",
    +		"Dra": "Drache",
    +		"Equ": "F&uuml;llen",
    +		"Eri": "Eridanus",
    +		"For": "Chemischer Ofen",
    +		"Gem": "Zwillinge",
    +		"Gru": "Kranich",
    +		"Her": "Herkules",
    +		"Hor": "Pendeluhr",
    +		"Hya": "Wasserschlange",
    +		"Hyi": "Kleine Wasserschlange",
    +		"Ind": "Indianer",
    +		"Lac": "Eidechse",
    +		"Leo": "L&ouml;we",
    +		"LMi": "Kleiner L&ouml;we",
    +		"Lep": "Hase",
    +		"Lib": "Waage",
    +		"Lup": "Wolf",
    +		"Lyn": "Luchs",
    +		"Lyr": "Leier",
    +		"Men": "Tafelberg",
    +		"Mic": "Mikroskop",
    +		"Mon": "Einhorn",
    +		"Mus": "Fliege",
    +		"Nor": "Winkelma&szsl;",
    +		"Oct": "Oktant",
    +		"Oph": "Schlangentr&auml;ger",
    +		"Ori": "Orion",
    +		"Pav": "Pfau",
    +		"Peg": "Pegasus",
    +		"Per": "Perseus",
    +		"Phe": "Phoenix",
    +		"Pic": "Maler",
    +		"Psc": "Fische",
    +		"PsA": "S&uuml;dlicher Fisch",
    +		"Pup": "Achterschiff",
    +		"Pyx": "Schiffskompass",
    +		"Ret": "Netz",
    +		"Sge": "Pfeil",
    +		"Sgr": "Sch&uuml;tze",
    +		"Sco": "Skorpion",
    +		"Scl": "Bildhauer",
    +		"Sct": "Schild",
    +		"Ser": "Schlange",
    +		"Sex": "Sextant",
    +		"Tau": "Stier",
    +		"Tel": "Teleslop",
    +		"Tri": "Dreieck",
    +		"TrA": "S&uuml;dliches Dreieck",
    +		"Tuc": "Tukan",
    +		"UMa": "Gro&szlig;er B&auml;r",
    +		"UMi": "Kleiner B&auml;r",
    +		"Vel": "Segel des Schiffs",
    +		"Vir": "Jungfrau",
    +		"Vol": "Fliegender Fisch",
    +		"Vul": "Fuchs"
    +	},
    +	"planets": {
    +		"Me": "Merkur",
    +		"V": "Venus",
    +		"Ma": "Mars",
    +		"J": "Jupiter",
    +		"S": "Saturn",
    +		"U": "Uranus",
    +		"N": "Neptun"
    +	},
    +	"sun":"Sonne",
    +	"moon":"Mond",
    +	"date": "Datum &amp; Uhrzeit",
    +	"datechange": "Datum/Uhrzeit &auml;ndern (gezeigt in Ihrer lokalen Zeit)",
    +	"close": "schlie&szlig;en",
    +	"position": "Breitengrad &amp; L&auml;engrad",
    +	"positionchange": "&Auml;ndern von L&auml;engrad/Breitengrad",
    +	"N": "N",
    +	"E": "O",
    +	"S": "S",
    +	"W": "W",
    +	"keyboard": "Tastaturk&uuml;rzel:",
    +	"fast": "Geschwindigkeit erh&ouml;hen",
    +	"stop": "Geschwindigkeit auf Null setzen",
    +	"slow": "Geschwindigkeit verringern",
    +	"reset": "Zeit auf aktuelle Zeit zur&uuml;cksetzen",
    +	"cardinal": "Kardinalpunkte an/aus",
    +	"stars": "Sterne an/aus",
    +	"starlabels": "Sternenbezeichnungen an/aus",
    +	"neg": "Farben umkehren",
    +	"atmos": "Atmosph&auml;re an/aus",
    +	"ground": "Boden an/aus",
    +	"az": "Az/El Gitterlinien an/aus",
    +	"eq": "Ra/Dec Gitterlinien an/aus",
    +	"gal": "Galaktische Gitterlinien an/aus",
    +	"galaxy": "Galaktische Ebene an/aus",
    +	"ec": "Ekliptik an/aus",
    +	"meridian": "Meridian an/aus",
    +	"con": "Linien der Sternbilder an/aus",
    +	"conbound": "Begrenzungen der Sternbilder an/aus",
    +	"names": "Namen der Sternbilder an/aus",
    +	"sol": "Planeten/Sonne/Mond an/aus",
    +	"sollabels": "Beschriftung von Planeten/Sonne/Mond an/aus",
    +	"orbits": "Planetenorbite an/aus",
    +	"projection":"Kartenprojektion &auml;ndern",
    +	"meteorshowers":"Meteorschauer Radiant an/aus",
    +	"addday": "1 Tag vor",
    +	"subtractday": "1 Tag zur&uuml;ck",
    +	"addweek": "1 Woche vor",
    +	"subtractweek": "1 Woche zur&uuml;ck",
    +	"azleft": "nach links drehen",
    +	"azright": "nach rechts drehen",
    +	"magup": "Gr&ouml;&szlig;engrenze erh&ouml;hen",
    +	"magdown": "Gr&ouml;&szlig;engrenze verringern",
    +	"left" : "&larr;",
    +	"right" : "&rarr;",
    +	"up": "&uarr;",
    +	"down": "&darr;",
    +	"power": "Powered by LCOGT",
    +	"projections": {
    +		"polar": "Polare Projektion",
    +		"fisheye": "Fischaugen-Projektion",
    +		"ortho": "Orthographische Projektion",
    +		"stereo": "Stereo-Projektion",
    +		"lambert": "Lambert-Projektion",
    +		"gnomic": "Gnomonische Projektion",
    +		"equirectangular": "Rektangularprojektion",
    +		"mollweide": "Mollweide-Projektion",
    +		"planechart": "Planare Projektion"
    +	},
    +	"starnames":{
    +		"7588":"Achernar","11767":"Polarstern","21421":"Aldebaran","24436":"Rigel","24608":"Capella","27989":"Betelgeuze",
    +		"30438":"Canopus","32349":"Sirius","33579":"Adara","37279":"Prokyon","37826":"Pollux","49669":"Regulus","62434":"Mimosa",
    +		"65378":"Mizar","65474":"Spica","68702":"Hadar","69673":"Arcturus","71683":"Alpha Centauri A","80763":"Antares","85927":"Shaula",
    +		"91262":"Vega","97649":"Altair","102098":"Deneb","113368":"Fomalhaut"
    +	}
    +}
    diff --git a/html/allsky/virtualsky/lang/en.json b/html/allsky/virtualsky/lang/en.json
    new file mode 100755
    index 000000000..7ad70f62b
    --- /dev/null
    +++ b/html/allsky/virtualsky/lang/en.json
    @@ -0,0 +1,174 @@
    +{
    +	"title": "VirtualSky",
    +	"language": {
    +		"code": "en",
    +		"name": "English",
    +		"alignment": "left",
    +		"translator": "Stuart Lowe"
    +	},
    +	"constellations": {
    +		"And": "Andromeda",
    +		"Ant": "Antlia",
    +		"Aps": "Apus",
    +		"Aqr": "Aquarius",
    +		"Aql": "Aquila",
    +		"Ara": "Ara",
    +		"Ari": "Aries",
    +		"Aur": "Auriga",
    +		"Boo": "Bo&ouml;tes",
    +		"Cae": "Caelum",
    +		"Cam": "Camelopardalis",
    +		"Cnc": "Cancer",
    +		"CVn": "Canes Venatici",
    +		"CMa": "Canis Major",
    +		"CMi": "Canis Minor",
    +		"Cap": "Capricornus",
    +		"Car": "Carina",
    +		"Cas": "Cassiopeia",
    +		"Cen": "Centaurus",
    +		"Cep": "Cepheus",
    +		"Cet": "Cetus",
    +		"Cha": "Chamaeleon",
    +		"Cir": "Circinus",
    +		"Col": "Columba",
    +		"Com": "Coma Berenices",
    +		"CrA": "Corona\nAustrina",
    +		"CrB": "Corona\nBorealis",
    +		"Crv": "Corvus",
    +		"Crt": "Crater",
    +		"Cru": "Crux",
    +		"Cyg": "Cygnus",
    +		"Del": "Delphinus",
    +		"Dor": "Dorado",
    +		"Dra": "Draco",
    +		"Equ": "Equuleus",
    +		"Eri": "Eridanus",
    +		"For": "Fornax",
    +		"Gem": "Gemini",
    +		"Gru": "Grus",
    +		"Her": "Hercules",
    +		"Hor": "Horologium",
    +		"Hya": "Hydra",
    +		"Hyi": "Hydrus",
    +		"Ind": "Indus",
    +		"Lac": "Lacerta",
    +		"Leo": "Leo",
    +		"LMi": "Leo Minor",
    +		"Lep": "Lepus",
    +		"Lib": "Libra",
    +		"Lup": "Lupus",
    +		"Lyn": "Lynx",
    +		"Lyr": "Lyra",
    +		"Men": "Mensa",
    +		"Mic": "Microscopium",
    +		"Mon": "Monoceros",
    +		"Mus": "Musca",
    +		"Nor": "Norma",
    +		"Oct": "Octans",
    +		"Oph": "Ophiuchus",
    +		"Ori": "Orion",
    +		"Pav": "Pavo",
    +		"Peg": "Pegasus",
    +		"Per": "Perseus",
    +		"Phe": "Phoenix",
    +		"Pic": "Pictor",
    +		"Psc": "Pisces",
    +		"PsA": "Piscis Austrinus",
    +		"Pup": "Puppis",
    +		"Pyx": "Pyxis",
    +		"Ret": "Reticulum",
    +		"Sge": "Sagitta",
    +		"Sgr": "Sagittarius",
    +		"Sco": "Scorpius",
    +		"Scl": "Sculptor",
    +		"Sct": "Scutum",
    +		"Ser": "Serpens",
    +		"Sex": "Sextans",
    +		"Tau": "Taurus",
    +		"Tel": "Telescopium",
    +		"Tri": "Triangulum",
    +		"TrA": "Triangulum\nAustrale",
    +		"Tuc": "Tucana",
    +		"UMa": "Ursa Major",
    +		"UMi": "Ursa Minor",
    +		"Vel": "Vela",
    +		"Vir": "Virgo",
    +		"Vol": "Volans",
    +		"Vul": "Vulpecula"
    +	},
    +	"planets": {
    +		"Me": "Mercury",
    +		"V": "Venus",
    +		"Ma": "Mars",
    +		"J": "Jupiter",
    +		"S": "Saturn",
    +		"U": "Uranus",
    +		"N": "Neptune"
    +	},
    +	"sun":"Sun",
    +	"moon":"Moon",
    +	"date": "Date &amp; Time",
    +	"datechange": "Change the date/time (shown in your local time)",
    +	"close": "close",
    +	"position": "Latitude &amp; Longitude",
    +	"positionchange": "Change the longitude/latitude",
    +	"N": "N",
    +	"E": "E",
    +	"S": "S",
    +	"W": "W",
    +	"keyboard": "Keyboard shortcuts:",
    +	"fast": "increase time speed",
    +	"stop": "set time rate to zero",
    +	"slow": "decrease time speed",
    +	"reset": "set time to now",
    +	"cardinal": "toggle cardinal points",
    +	"stars": "toggle stars",
    +	"starlabels": "toggle star labels",
    +	"neg": "invert colours",
    +	"atmos": "toggle atmosphere",
    +	"ground": "toggle ground",
    +	"az": "toggle Az/El gridlines",
    +	"eq": "toggle Ra/Dec gridlines",
    +	"gal": "toggle Galactic gridlines",
    +	"galaxy": "toggle Galactic plane",
    +	"ec": "toggle Ecliptic line",
    +	"meridian": "toggle Meridian line",
    +	"con": "toggle constellation lines",
    +	"conbound": "toggle constellation boundaries",
    +	"names": "toggle constellation names",
    +	"sol": "toggle planets/Sun/Moon",
    +	"sollabels": "toggle planet/Sun/Moon labels",
    +	"orbits": "toggle planet orbits",
    +	"projection":"change map projection",
    +	"meteorshowers":"toggle meteor shower radiants",
    +	"addday": "add 1 day",
    +	"subtractday": "subtract 1 day",
    +	"addweek": "add 1 week",
    +	"subtractweek": "subtract 1 week",
    +	"azleft": "rotate left",
    +	"azright": "rotate right",
    +	"magup": "increase magnitude limit",
    +	"magdown": "decrease magnitude limit",
    +	"left" : "&larr;",
    +	"right" : "&rarr;",
    +	"up": "&uarr;",
    +	"down": "&darr;",
    +	"power": "Powered by LCOGT",
    +	"projections": {
    +		"polar": "Polar projection",
    +		"fisheye": "Fisheye projection",
    +		"ortho": "Orthographic projection",
    +		"stereo": "Stereo projection",
    +		"lambert": "Lambert projection",
    +		"gnomic": "Gnomic projection",
    +		"equirectangular": "Equirectangular projection",
    +		"mollweide": "Mollweide projection",
    +		"planechart": "Planechart projection"
    +	},
    +	"starnames":{
    +		"7588":"Achernar","11767":"Polaris","21421":"Aldebaran","24436":"Rigel","24608":"Capella","27989":"Betelgeuse",
    +		"30438":"Canopus","32349":"Sirius","33579":"Adara","37279":"Procyon","37826":"Pollux","49669":"Regulus","62434":"Mimosa",
    +		"65378":"Mizar","65474":"Spica","68702":"Hadar","69673":"Arcturus","71683":"Alpha Centauri A","80763":"Antares","85927":"Shaula",
    +		"91262":"Vega","97649":"Altair","102098":"Deneb","113368":"Fomalhaut"
    +	}
    +}
    diff --git a/html/allsky/virtualsky/lang/es.json b/html/allsky/virtualsky/lang/es.json
    new file mode 100755
    index 000000000..e0016150b
    --- /dev/null
    +++ b/html/allsky/virtualsky/lang/es.json
    @@ -0,0 +1,194 @@
    +{
    +	"title": "VirtualSky",
    +	"language": {
    +		"code": "es",
    +		"name": "Espa&#241;ol",
    +		"alignment": "left",
    +		"translator": "Marcos Saldivar and Jorge Fernandez"
    +	},
    +	"constellations": {
    +		"And": "Andr&#243;meda",
    +		"Ant": "La M&#225;quina neum&#225;tica",
    +		"Aps": "El Ave del Para&#237;so",
    +		"Aqr": "Acuario",
    +		"Aql": "El &#193;guila",
    +		"Ara": "El Altar",
    +		"Ari": "Aries",
    +		"Aur": "Auriga",
    +		"Boo": "El Boyero",
    +		"Cae": "Caelum",
    +		"Cam": "La Jirafa",
    +		"Cnc": "C&#225;ncer",
    +		"CVn": "Canes Venatici",
    +		"CMa": "El Perro Mayor",
    +		"CMi": "El Perro peque&#241;o",
    +		"Cap": "Capricornio",
    +		"Car": "Carina",
    +		"Cas": "Casiopea",
    +		"Cen": "El Centauro",
    +		"Cep": "Cefeo",
    +		"Cet": "Ceto",
    +		"Cha": "El Camale&#243;n",
    +		"Cir": "El Comp&#225;s",
    +		"Col": "La Paloma",
    +		"Com": "La cabellera de Berenice",
    +		"CrA": "La Corona Austral",
    +		"CrB": "La Corona Boreal",
    +		"Crv": "El Cuervo",
    +		"Crt": "La Copa",
    +		"Cru": "La Cruz",
    +		"Cyg": "El Cisne",
    +		"Del": "El Delf&#237;n",
    +		"Dor": "El Pez dorado",
    +		"Dra": "El Drag&#243;n",
    +		"Equ": "El Caballo",
    +		"Eri": "El R&#237;o",
    +		"For": "El Horno",
    +		"Gem": "Los Gemelos",
    +		"Gru": "La Grulla",
    +		"Her": "H&#233;rcules",
    +		"Hor": "Reloj",
    +		"Hya": "Hydra",
    +		"Hyi": "La Serpiente marina",
    +		"Ind": "El Indio",
    +		"Lac": "Lagarto",
    +		"Leo": "Le&#243;n",
    +		"LMi": "Le&#243; peque&#241;o",
    +		"Lep": "Conejo",
    +		"Lib": "La Balanza",
    +		"Lup": "Lobo",
    +		"Lyn": "Lince",
    +		"Lyr": "La Lira",
    +		"Men": "La Mesa",
    +		"Mic": "Microscopio",
    +		"Mon": "El Unicornio",
    +		"Mus": "La Mosca",
    +		"Nor": "Regla",
    +		"Oct": "El Octante",
    +		"Oph": "Ofiuco",
    +		"Ori": "Ori&#243;n",
    +		"Pav": "El Pavo",
    +		"Peg": "Pegaso",
    +		"Per": "Perseo",
    +		"Phe": "El F&#233;nix",
    +		"Pic": "La Paleta del Pintor",
    +		"Psc": "Los Peces",
    +		"PsA": "Pez Austral",
    +		"Pup": "La Popa",
    +		"Pyx": "Br&#250;jula",
    +		"Ret": "El Ret&#237;culo",
    +		"Sge": "Flecha",
    +		"Sgr": "Sagitario",
    +		"Sco": "El Escorpi&#243;n",
    +		"Scl": "Escultor",
    +		"Sct": "Escudo",
    +		"Ser": "La Serpiente",
    +		"Sex": "El Sextante",
    +		"Tau": "Tauro",
    +		"Tel": "Telescopio",
    +		"Tri": "Tri&#225;ngulo",
    +		"TrA": "El Tri&#225;ngulo Austral",
    +		"Tuc": "El Tuc&#225;n",
    +		"UMa": "Oso Mayor",
    +		"UMi": "Oso Peque&#241;o",
    +		"Vel": "Vela",
    +		"Vir": "Virgo",
    +		"Vol": "El Pez volador",
    +		"Vul": "El Zorro"
    +	},
    +	"planets": {
    +		"Me": "Mercurio",
    +		"V": "Venus",
    +		"Ma": "Marte",
    +		"J": "J&#250;piter",
    +		"S": "Saturno",
    +		"U": "Urano",
    +		"N": "Neptuno"
    +	},
    +	"sun": "Sol",
    +	"moon": "Luna",
    +	"date": "Fecha y Hora",
    +	"datechange": "cambio de fecha/hora",
    +	"close": "cerrar",
    +	"position": "Latitud &amp; Longitud",
    +	"positionchange": "cambiar Longitud/Latitud",
    +	"N": "Norte",
    +	"E": "Este",
    +	"S": "Sur",
    +	"W": "Oeste",
    +	"keyboard": "combinaci&oacute;n de teclas",
    +	"fast": "aumentar velocidad temporal",
    +	"stop": "velocidad temporal a zero",
    +	"slow": "disminuir velocidad temporal",
    +	"reset": "establecer tiempo a ahora",
    +	"cardinal": "puntos cardinales",
    +	"stars": "estrellas",
    +	"starlabels": "etiquetas de estrellas",
    +	"neg": "invertir colores",
    +	"atmos": "atm&#243;sfera",
    +	"ground": "suelo",
    +	"az": "Ra/Dec cuadr&iacute;ula",
    +	"eq": "cuadr&iacute;ula ecuatorial",
    +	"gal": "cuadr&iacute;ula gal&aacute;ctico",
    +	"galaxy": "plano gal&#225;ctico",
    +	"ec": "ecl&#237;ptica",
    +	"meridian": "meridiano",
    +	"con": "l&#237;neas de las constelaciones",
    +	"conbound": "l&#237;mites de las constelaciones",
    +	"names": "nombre de las constelaciones",
    +	"sol": "planetas/Sol/Luna",
    +	"sollabels": "etiquetas planetas/Sol/Luna",
    +	"orbits": "&#243;rbitas de planetas",
    +	"projection": "cambiar proyecci&#243;n",
    +	"meteorshowers": "lluvia de meteor&iacute;tos",
    +	"addday": "aumentar 1 d&#237;a",
    +	"subtractday": "disminuir 1 d&#237;a",
    +	"addweek": "aumentar 1 semana",
    +	"subtractweek": "disminuir 1 semana",
    +	"azleft": "rotar izquierda",
    +	"azright": "rotar derecha",
    +	"magup": "aumentar magnitud l&iacute;mite",
    +	"magdown": "disminuir magnitud l&iacute;mite",
    +	"left": "&larr;",
    +	"right": "&rarr;",
    +	"up": "&uarr;",
    +	"down": "&darr;",
    +	"power": "conducido por LCO",
    +	"projections": {
    +		"polar": "proyecci&oacute;n polar",
    +		"fisheye": "proyecci&oacute;n ojo de pez",
    +		"ortho": "proyecci&oacute;n ortogr&aacute;fica",
    +		"stereo": "proyecci&oacute;n estereogr&aacute;fica",
    +		"lambert": "proyecci&oacute;n de Lambert",
    +		"gnomic": "proyecci&oacute;n gnom&oacute;nica",
    +		"equirectangular": "proyecci&oacute;n cil&iacute;ndrica equidistante",
    +		"mollweide": "proyecci&oacute;n de Mollweide",
    +		"planechart": "proyecci&oacute;n geogr&aacute;fica"
    +	},
    +	"starnames": {
    +		"7588": "Achernar",
    +		"11767": "Polaris",
    +		"21421": "Aldebaran",
    +		"24436": "Rigel",
    +		"24608": "Capella",
    +		"27989": "Betelgeuse",
    +		"30438": "Canopus",
    +		"32349": "Sirio",
    +		"33579": "Adara",
    +		"37279": "Proci&oacute;n",
    +		"37826": "P&oacute;lux",
    +		"49669": "Regulus",
    +		"62434": "Mimosa",
    +		"65378": "Mizar",
    +		"65474": "Spica",
    +		"68702": "Hadar",
    +		"69673": "Arturo",
    +		"71683": "Alfa Centauri A",
    +		"80763": "Antares",
    +		"85927": "Shaula",
    +		"91262": "Vega",
    +		"97649": "Altair",
    +		"102098": "Deneb",
    +		"113368": "Fomalhaut"
    +	}
    +}
    diff --git a/html/allsky/virtualsky/lang/fr.json b/html/allsky/virtualsky/lang/fr.json
    new file mode 100755
    index 000000000..67f1fadeb
    --- /dev/null
    +++ b/html/allsky/virtualsky/lang/fr.json
    @@ -0,0 +1,163 @@
    +{
    +	"title": "VirtualSky",
    +	"language": {
    +		"code": "fr",
    +		"name": "Fran&#231;ais",
    +		"alignment": "left",
    +		"translator": "fphg"
    +	},
    +	"constellations": {
    +		"And": "Androm&egrave;de",
    +		"Ant": "Machine\npneumatique",
    +		"Aps": "Oiseau de paradis",
    +		"Aqr": "Verseau",
    +		"Aql": "Aigle",
    +		"Ara": "Autel",
    +		"Ari": "B&eacute;lier",
    +		"Aur": "Cocher",
    +		"Boo": "Bouvier",
    +		"Cae": "Burin",
    +		"Cam": "Girafe",
    +		"Cnc": "Cancer",
    +		"CVn": "Chiens de chasse",
    +		"CMa": "Grand Chien",
    +		"CMi": "Petit Chien",
    +		"Cap": "Capricorne",
    +		"Car": "Car&egrave;ne",
    +		"Cas": "Cassiop&eacute;e",
    +		"Cen": "Centaure",
    +		"Cep": "C&eacute;ph&eacute;e",
    +		"Cet": "Baleine",
    +		"Cha": "Cam&eacute;l&eacute;on",
    +		"Cir": "Compas",
    +		"Col": "Colombe",
    +		"Com": "Chevelure de\nB&eacute;renice",
    +		"CrA": "Couronne\nAustrale",
    +		"CrB": "Couronne\nBor&eacute;ale",
    +		"Crv": "Corbeau",
    +		"Crt": "Coupe",
    +		"Cru": "Croix du Sud",
    +		"Cyg": "Cygne",
    +		"Del": "Dauphin",
    +		"Dor": "Dorade",
    +		"Dra": "Dragon",
    +		"Equ": "Petit Cheval",
    +		"Eri": "Eridan",
    +		"For": "Fourneau",
    +		"Gem": "G&eacute;meaux",
    +		"Gru": "Grue",
    +		"Her": "Hercule",
    +		"Hor": "Horologe",
    +		"Hya": "Hydre",
    +		"Hyi": "Hydre m&acirc;le",
    +		"Ind": "Indien",
    +		"Lac": "L&eacute;zard",
    +		"Leo": "Lion",
    +		"LMi": "Petit Lion",
    +		"Lep": "Li&egrave;vre",
    +		"Lib": "Balance",
    +		"Lup": "Loup",
    +		"Lyn": "Lynx",
    +		"Lyr": "Lyre",
    +		"Men": "Table",
    +		"Mic": "Microscope",
    +		"Mon": "Licorne",
    +		"Mus": "Mouche",
    +		"Nor": "R&egrave;gle",
    +		"Oct": "Octant",
    +		"Oph": "Ophiuchus",
    +		"Ori": "Orion",
    +		"Pav": "Paon",
    +		"Peg": "P&eacute;gase",
    +		"Per": "Pers&eacute;e",
    +		"Phe": "Ph&eacute;nix",
    +		"Pic": "Peintre",
    +		"Psc": "Poissons",
    +		"PsA": "Poisson austral",
    +		"Pup": "Poupe",
    +		"Pyx": "Boussole",
    +		"Ret": "R&eacute;ticule",
    +		"Sge": "Fl&egrave;che",
    +		"Sgr": "Sagittaire",
    +		"Sco": "Scorpion",
    +		"Scl": "Sculpteur",
    +		"Sct": "Ecu de\nSobieski",
    +		"Ser": "Serpent",
    +		"Sex": "Sextant",
    +		"Tau": "Taureau",
    +		"Tel": "T&eacute;lescope",
    +		"Tri": "Triangle",
    +		"TrA": "Triangle\naustral",
    +		"Tuc": "Toucan",
    +		"UMa": "Grande Ourse",
    +		"UMi": "Petite Ourse",
    +		"Vel": "Voiles",
    +		"Vir": "Vierge",
    +		"Vol": "Poisson volant",
    +		"Vul": "Petit Renard"
    +	},
    +	"planets": {
    +		"Me": "Mercure",
    +		"V": "Venus",
    +		"Ma": "Mars",
    +		"J": "Jupiter",
    +		"S": "Saturne",
    +		"U": "Uranus",
    +		"N": "Neptune"
    +	},
    +	"sun":"Soleil",
    +	"moon":"Lune",
    +	"date": "Date &amp; Heure",
    +	"datechange": "Changer date/heure (pr&eacute;sent&eacute;e en heure locale)",
    +	"close": "fermer",
    +	"position": "Latitude &amp; Longitude",
    +	"positionchange": "Changer longitude/latitude",
    +	"N": "N",
    +	"E": "E",
    +	"S": "S",
    +	"W": "O",
    +	"keyboard": "Raccourcis clavier:",
    +	"fast": "augmenter vitesse temps",
    +	"stop": "vitesse temps &agrave; z&eacute;ro",
    +	"slow": "r&eacute;duire vitesse temps",
    +	"reset": "temps &agrave; maintenant",
    +	"cardinal": "basculer points cardinaux",
    +	"stars": "afficher &eacute;toiles",
    +	"starlabels": "basculer noms &eacute;toiles",
    +	"neg": "inverser couleurs",
    +	"atmos": "basculer atmosph&egrave;re",
    +	"ground": "basculer sol",
    +	"az": "afficher grille Az/El",
    +	"eq": "afficher grille Ra/Dec",
    +	"gal": "afficher grille galactique",
    +	"galaxy": "afficher plan galactique",
    +	"ec": "afficher ligne &eacute;cliptique",
    +	"meridian": "afficher ligne m&eacute;dirien",
    +	"con": "afficher lignes constellations",
    +	"conbound": "afficher limites constellations",
    +	"names": "afficher noms constellations",
    +	"sol": "afficher plan&egrave;tes/Soleil/Lune",
    +	"sollabels": "afficher noms plan&egrave;tes/Soleil/Lune",
    +	"orbits": "afficher orbites plan&egrave;tes",
    +	"projection":"changer projection carte",
    +	"meteorshowers":"afficher pluie de m&eacute;t&eacute;ores",
    +	"addday": "ajouter 1 jour",
    +	"subtractday": "soustraire 1 jour",
    +	"addweek": "ajouter 1 semaine",
    +	"subtractweek": "soustraire 1 semaine",
    +	"azleft": "tourner vers gauche",
    +	"azright": "tourner vers droite",
    +	"magup": "augmenter magnitude limite",
    +	"magdown": "r&eacute;duire magnitude limite",
    +	"left" : "&larr;",
    +	"right" : "&rarr;",
    +	"up": "&uarr;",
    +	"down": "&darr;",
    +	"power": "powered by LCOGT",
    +	"starnames":{
    +		"7588":"Achernar","11767":"Polaris","21421":"Aldebaran","24436":"Rigel","24608":"Capella","27989":"Betelgeuse",
    +		"30438":"Canopus","32349":"Sirius","33579":"Adara","37279":"Procyon","37826":"Pollux","49669":"Regulus","62434":"Mimosa",
    +		"65378":"Mizar","65474":"Spica","68702":"Hadar","69673":"Arcturus","71683":"Alpha Centauri A","80763":"Antares","85927":"Shaula",
    +		"91262":"Vega","97649":"Altair","102098":"Deneb","113368":"Fomalhaut"
    +	}
    +}
    \ No newline at end of file
    diff --git a/html/allsky/virtualsky/lang/gl.json b/html/allsky/virtualsky/lang/gl.json
    new file mode 100644
    index 000000000..0f14b7749
    --- /dev/null
    +++ b/html/allsky/virtualsky/lang/gl.json
    @@ -0,0 +1,194 @@
    +{
    +	"title": "VirtualSky",
    +	"language": {
    +		"code": "gl",
    +		"name": "Galego",
    +		"alignment": "left",
    +		"translator": "Martin Pawley"
    +	},
    +	"constellations": {
    +		"And": "Andrómeda",
    +		"Ant": "Máquina Pneumática",
    +		"Aps": "Ave do Paraíso",
    +		"Aqr": "Augadeiro",
    +		"Aql": "Aguia",
    +		"Ara": "Altar",
    +		"Ari": "Carneiro",
    +		"Aur": "Auriga",
    +		"Boo": "Boieiro",
    +		"Cae": "Cicel",
    +		"Cam": "Xirafa",
    +		"Cnc": "Cangrexo",
    +		"CVn": "Cans de Caza",
    +		"CMa": "Can Maior",
    +		"CMi": "Can Menor",
    +		"Cap": "Capricornio",
    +		"Car": "Quilla",
    +		"Cas": "Casiopea",
    +		"Cen": "Centauro",
    +		"Cep": "Cefeo",
    +		"Cet": "Balea",
    +		"Cha": "Camaleón",
    +		"Cir": "Compás de Debuxo",
    +		"Col": "Pomba",
    +		"Com": "Cabeleira de Berenice",
    +		"CrA": "Coroa Austral",
    +		"CrB": "Coroa Boreal",
    +		"Crv": "Corvo",
    +		"Crt": "Copa",
    +		"Cru": "Cruz do Sur",
    +		"Cyg": "Cisne",
    +		"Del": "Golfiño",
    +		"Dor": "Dourado",
    +		"Dra": "Dragón",
    +		"Equ": "Cabaliño",
    +		"Eri": "Erídano",
    +		"For": "Forno",
    +		"Gem": "Xemelgos",
    +		"Gru": "Grou",
    +		"Her": "Hércules",
    +		"Hor": "Reloxo",
    +		"Hya": "Hidra",
    +		"Hyi": "Hidra Austral",
    +		"Ind": "Indio",
    +		"Lac": "Lagarto",
    +		"Leo": "León",
    +		"LMi": "León Menor",
    +		"Lep": "Lebre",
    +		"Lib": "Balanza",
    +		"Lup": "Lobo",
    +		"Lyn": "Lince",
    +		"Lyr": "Lira",
    +		"Men": "Monte da Mesa",
    +		"Mic": "Microscopio",
    +		"Mon": "Unicornio",
    +		"Mus": "Mosca",
    +		"Nor": "Escuadro",
    +		"Oct": "Octante",
    +		"Oph": "Serpentario",
    +		"Ori": "Orión",
    +		"Pav": "Pavón",
    +		"Peg": "Pegaso",
    +		"Per": "Perseo",
    +		"Phe": "Ave Fénix",
    +		"Pic": "Cabalete de Pintor",
    +		"Psc": "Peixes",
    +		"PsA": "Peixe Austral",
    +		"Pup": "Popa",
    +		"Pyx": "Compás",
    +		"Ret": "Retículo",
    +		"Sge": "Seta",
    +		"Sgr": "Seteiro",
    +		"Sco": "Escorpión",
    +		"Scl": "Escultor",
    +		"Sct": "Escudo",
    +		"Ser": "Serpe",
    +		"Sex": "Sextante",
    +		"Tau": "Touro",
    +		"Tel": "Telescopio",
    +		"Tri": "Triángulo",
    +		"TrA": "Triángulo Austral",
    +		"Tuc": "Tucano",
    +		"UMa": "Osa Maior",
    +		"UMi": "Osa Menor",
    +		"Vel": "Velame",
    +		"Vir": "Virxe",
    +		"Vol": "Peixe Voador",
    +		"Vul": "Raposiña"
    +	},
    +	"planets": {
    +		"Me": "Mercurio",
    +		"V": "Venus",
    +		"Ma": "Marte",
    +		"J": "Xúpiter",
    +		"S": "Saturno",
    +		"U": "Urano",
    +		"N": "Neptuno"
    +	},
    +	"sun": "Sol",
    +	"moon": "Lúa",
    +	"date": "Día e hora",
    +	"datechange": "Cambiar o día/hora (amosada na hora local)",
    +	"close": "Pechar",
    +	"position": "Latitude e lonxitude",
    +	"positionchange": "Cambiar a lonxitude/latitude",
    +	"N": "N",
    +	"E": "L",
    +	"S": "S",
    +	"W": "O",
    +	"keyboard": "Atallos de teclado:",
    +	"fast": "Aumentar a velocidade de paso do tempo",
    +	"stop": "Fixar o ratio temporal a cero",
    +	"slow": "Reducir a velocidade de paso do tempo",
    +	"reset": "Volver ao tempo actual",
    +	"cardinal": "Poñer ou quitar puntos cardinais",
    +	"stars": "Poñer ou quitar estrelas",
    +	"starlabels": "Poñer ou quitar etiquetas de estrelas",
    +	"neg": "Inverter cores",
    +	"atmos": "Poñer ou quitar atmosfera",
    +	"ground": "Poñer ou quitar o chan",
    +	"az": "Poñer ou quitar grella Az/El",
    +	"eq": "Poñer ou quitar grella Ar/Dec",
    +	"gal": "Poñer ou quitar grella galáctica",
    +	"galaxy": "Poñer ou quitar plano galáctico",
    +	"ec": "Poñer ou quitar a eclíptica",
    +	"meridian": "Poñer ou quitar liña do meridiano",
    +	"con": "Poñer ou quitar liñas de constelacións",
    +	"conbound": "Poñer ou quitar fronteiras de constelacións",
    +	"names": "Poñer ou quitar nomes de constelacións",
    +	"sol": "Poñer ou quitar planetas/Sol/Lúa",
    +	"sollabels": "Poñer ou quitar etiquetas de planetas/Sol/Lúa",
    +	"orbits": "Poñer ou quitar órbitas de planetas",
    +	"projection": "Cambiar proxección do mapa",
    +	"meteorshowers": "Poñer ou quitar radiantes de chuvias de meteoros",
    +	"addday": "Sumar 1 día",
    +	"subtractday": "Restar 1 día",
    +	"addweek": "Sumar 1 semana",
    +	"subtractweek": "Restar 1 semana",
    +	"azleft": "Xirar á esquerda",
    +	"azright": "Xirar á dereita",
    +	"magup": "Incrementar límite de magnitude",
    +	"magdown": "Reducir límite de magnitude",
    +	"left": "?",
    +	"right": "?",
    +	"up": "?",
    +	"down": "?",
    +	"power": "Fornecido por LCO",
    +	"projections": {
    +		"polar": "Proxección polar",
    +		"fisheye": "Proxección de ollo de peixe",
    +		"ortho": "Proxección ortográfica",
    +		"stereo": "Proxección estereográfica",
    +		"lambert": "Proxección Lambert",
    +		"gnomic": "Proxección gnomónica",
    +		"equirectangular": "Proxección cilíndrica equidistante",
    +		"mollweide": "Proxección Mollweide",
    +		"planechart": "Proxección de mapa plano"
    +	},
    +	"starnames": {
    +		"7588": "Achernar",
    +		"11767": "Polar",
    +		"21421": "Aldebarán",
    +		"24436": "Rigel",
    +		"24608": "Capella",
    +		"27989": "Betelgeuse",
    +		"30438": "Canopus",
    +		"32349": "Sirio",
    +		"33579": "Adara",
    +		"37279": "Proción",
    +		"37826": "Pollux",
    +		"49669": "Regulus",
    +		"62434": "Mimosa",
    +		"65378": "Mizar",
    +		"65474": "Spica",
    +		"68702": "Hadar",
    +		"69673": "Arcturus",
    +		"71683": "Alpha Centauri A",
    +		"80763": "Antares",
    +		"85927": "Shaula",
    +		"91262": "Vega",
    +		"97649": "Altair",
    +		"102098": "Deneb",
    +		"113368": "Fomalhaut"
    +	}
    +}
    diff --git a/html/allsky/virtualsky/lang/index.html b/html/allsky/virtualsky/lang/index.html
    new file mode 100755
    index 000000000..49b15b305
    --- /dev/null
    +++ b/html/allsky/virtualsky/lang/index.html
    @@ -0,0 +1,200 @@
    +<html xmlns="http://www.w3.org/1999/xhtml">
    +<head>
    +	<title>Translation | VirtualSky</title>
    +	<link rel="stylesheet" href="../css/style.css" type="text/css" />
    +	<!-- IE seems to get very confused if it loads Javascript from within a Javascript when in an iframe. We'll have to load it here -->
    +	<!--[if IE]><script src="../excanvas.min.js"></script><![endif]-->
    +	<script src="../stuquery.js"></script>
    +	<script src="../virtualsky.js" type="text/javascript"></script>
    +	<script src="translate.js"></script>
    +	<script type="text/javascript">
    +		var translator;
    +		S(document).ready(function(){
    +		
    +			planetarium = S.virtualsky({id:'starmapper',projection:'stereo',constellations:true,constellationlabels:true});
    +
    +			langs = {};
    +			for(var l in planetarium.langs){
    +				langs[l] = { "name": planetarium.langs[l].language.name, "file": l+".json" };
    +				if(l=="en") langs[l]['default'] = true;
    +			}
    +			translator = new Translator({
    +				'id':'form',
    +				'help': 'master.json',
    +				'langs': langs,
    +				'callback': {
    +					'update': function(a){
    +						//a.json = JSON.parse(a.json.replace(/\&quot;/g,"\""));
    +						planetarium.lang = a.json;
    +						planetarium.langcode = a.lang;
    +						planetarium.draw();
    +					}
    +				}
    +			});
    +		});
    +	</script>
    +	<style type="text/css">
    +
    +
    +		body {
    +			font-family: 'Trebuchet MS',Tahoma,Helvetica,Verdana,sans-serif;
    +		}
    +		.holder {
    +			width:800px;
    +			background-color: white;
    +			padding: 2em;
    +			margin: 1em auto;
    +		}
    +		h1 { font-size: 1.75em; }
    +		h2 { font-size: 1.5em; }
    +		p { margin: 1em 0;}
    +		#starmapper { width:100%; height:300px; font-size:11px; position:relative; }
    +		#fake { display: none; }
    +		#chooser {
    +			margin-bottom: 20px;
    +		}
    +		#complete { font-weight: bold; }
    +		p.warning { padding: 0.5em; }
    +		.warning { background-color: #fff6bf; color: #817134; }
    +		.error { background-color: #fbe3e4; color: #d12f19; border: 2px solid #d12f19; }
    +		button { border: 0px; background-color: #60adfa; padding: 0.5em; border-radius: 2px; color: white; cursor: pointer; }
    +		div.twocol {
    +			width: 290px;
    +			float: left;
    +			margin-right: 10px;
    +			display: inline-block;
    +			color: #333333;
    +		}
    +		div.twocol label {
    +			margin-top: 0.5em;
    +		}
    +		div.fourcol {
    +			width: 500px;
    +			display: inline-block;
    +		}
    +		.rtl { text-align: right; }
    +		.rtl div.twocol { float: right; margin-left: 10px; margin-right: 0px; }
    +		div.subt {
    +			width: 770px;
    +			display: inline-block;
    +		}
    +		div.subt p {
    +			margin-top:-0.25em;
    +		}
    +		div.fourcol div.default {
    +			background-color:#ddd;
    +			padding:0.5em;
    +		}
    +		fieldset {
    +			padding: 0.5em 0;
    +			border: 0px;
    +			margin: 0px 0px 0.5em 0px;
    +		}
    +		fieldset.same {
    +			background-color: #ffcccc;
    +		}
    +		fieldset p { 
    +			margin: 0px;
    +		}
    +		.group {
    +			background-color: #e9e9e9;
    +			margin-bottom: 1em;
    +			padding: 8px;
    +		}
    +		.group fieldset:last-child {
    +			margin-bottom: 0px;
    +		}
    +		label {
    +			margin-right: 10px;
    +		}
    +		legend {
    +			margin:0px;
    +			padding: 0px;
    +			font-weight: bold;
    +		}
    +		input {
    +			width:100%;
    +			font-family: 'Trebuchet MS',Tahoma,Helvetica,Verdana,sans-serif;
    +			font-size: 1em;
    +		}
    +		textarea {
    +			width:100%;
    +			height: 400px;
    +			font-family: 'Trebuchet MS',Tahoma,Helvetica,Verdana,sans-serif;
    +			font-size: 1em;
    +		}
    +		ul i {
    +			color: #999;
    +		}
    +		pre { overflow-x: auto; width: 100%; }
    +		pre.code {
    +			border-bottom-right-radius: 1em;
    +			border-top-right-radius: 1em;
    +			margin-left: 0em;
    +			overflow-x: auto;
    +			padding: 1em;
    +			padding-left: 0em;
    +		}
    +		pre.code .b {
    +			color: blue!important;
    +			font-weight: normal!important;
    +		}
    +		pre.code .c {
    +			color: #009E00!important;
    +		}
    +		pre.code .d,ul li code.d {
    +			color: #CF6A4C!important;
    +		}
    +		pre.code .s {
    +			color: #82C6BC!important;
    +		}
    +		pre.code .f {
    +			color: #BE0750!important;
    +		}
    +		pre.code .bracket {
    +			color: black;
    +			font-weight:bold;
    +		}
    +
    +		#progressbar {
    +		  height: 0.3em;
    +		  left: 0;
    +		  top: 0;
    +		  width: 100%;
    +		  position: fixed;
    +		  background-color: #404040;
    +		}
    +
    +		#progressbar .progress-inner {
    +		  background-color: #60adfa;
    +		  width: 0%;
    +		  height: 100%;
    +		  position: absolute;
    +		  left: 0px;
    +		  top: 0px;
    +		  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
    +		  -webkit-transition: width 0.5s ease-in-out;
    +		  -moz-transition: width 0.5s ease-in-out;
    +		  -ms-transition: width 0.5s ease-in-out;
    +		  -o-transition: width 0.5s ease-in-out;
    +		  transition: width 0.5s ease-in-out;
    +		}
    +    	</style>
    +</head>
    +<body>
    +	<div id="progressbar"><div class="progress-inner" style="width:0.0%"></div></div>
    +	<div class="holder">
    +		<h1>VirtualSky Translation Page</h1>
    +		<div id="page">
    +			<p>The form below contains all the language fields used in <a href="../index.html">VirtualSky</a> (<a href="viewer.html?lang=" class="langlinkcat">viewer in <span class="langname"></span></a>). The fields are split into sections. If you'd like to start translating a language that isn't on the drop down list please contact <a href="http://twitter.com/astronomyblog">Stuart</a>.</p>
    +			<p style="font-size:1.2em;"><strong>If you want your changes to be applied you'll have to copy/paste the <a href="#output">output</a> from the bottom into the appropriate language file. This form does not automatically save!</strong></p>
    +			<p class="warning"><strong>If you want your changes to be applied you'll have to copy/paste the <a href="#output">output</a> into a file and <a href="https://github.com/slowe/VirtualSky/pulls">submit a pull request on Github</a>.</strong></p>
    +		</div>
    +		<div id="form"></div>
    +		<h2>Output</h2>
    +		<div id="starmapper"></div>
    +		<p>Here is the output. You should copy and paste this into the <a href="https://github.com/slowe/VirtualSky/tree/master/lang" class="langfile">appropriate language</a> file.</p>
    +		<div id="output"></div>
    +	</div>
    +</body>
    +</html>
    diff --git a/html/allsky/virtualsky/lang/master.json b/html/allsky/virtualsky/lang/master.json
    new file mode 100755
    index 000000000..865ddef58
    --- /dev/null
    +++ b/html/allsky/virtualsky/lang/master.json
    @@ -0,0 +1,199 @@
    +{
    +	"title": { "_title": "Main title", "_text": "VirtualSky", "_type": "string" },
    +	"language" : {
    +		"_title": "About the language",
    +		"code" : { "_title": "Language Code", "_text": "The two letter language code", "_type": "string" },
    +		"name" : { "_title": "Language Name", "_text": "This is the name of the language in that language e.g. English, Fran&#231;ais, Espa&#241;ol, and &#2350;&#2366;&#2344;&#2325; &#2361;&#2367;&#2344;&#2381;&#2342;&#2368;. It appears in the language menu.", "_type": "string" },
    +		"translator" : { "_title": "Translator", "_text": "This is the name of the translator. It can contain a hyperlink. Not currently displayed but may be in the future.", "_type": "string" },
    +		"alignment" : { "_title": "Language Direction", "_text": "The direction that the language goes.", "_type": "select", "_options": [{"name":"left to right","value":"left"},{"name":"right to left","value":"right"}] }
    +	},
    +	"constellations": {
    +		"_title": "Constellations",
    +		"And": { "_title": "Andromeda", "_text": "The name of the constellation", "_type": "string" },
    +		"Ant": { "_title": "Antlia", "_text": "The name of the constellation", "_type": "string" },
    +		"Aps": { "_title": "Apus", "_text": "The name of the constellation", "_type": "string" },
    +		"Aqr": { "_title": "Aquarius", "_text": "The name of the constellation", "_type": "string" },
    +		"Aql": { "_title": "Aquila", "_text": "The name of the constellation", "_type": "string" },
    +		"Ara": { "_title": "Ara", "_text": "The name of the constellation", "_type": "string" },
    +		"Ari": { "_title": "Aries", "_text": "The name of the constellation", "_type": "string" },
    +		"Aur": { "_title": "Auriga", "_text": "The name of the constellation", "_type": "string" },
    +		"Boo": { "_title": "Bo&ouml;tes", "_text": "The name of the constellation", "_type": "string" },
    +		"Cae": { "_title": "Caelum", "_text": "The name of the constellation", "_type": "string" },
    +		"Cam": { "_title": "Camelopardalis", "_text": "The name of the constellation", "_type": "string" },
    +		"Cnc": { "_title": "Cancer", "_text": "The name of the constellation", "_type": "string" },
    +		"CVn": { "_title": "Canes Venatici", "_text": "The name of the constellation", "_type": "string" },
    +		"CMa": { "_title": "Canis Major", "_text": "The name of the constellation", "_type": "string" },
    +		"CMi": { "_title": "Canis Minor", "_text": "The name of the constellation", "_type": "string" },
    +		"Cap": { "_title": "Capricornus", "_text": "The name of the constellation", "_type": "string" },
    +		"Car": { "_title": "Carina", "_text": "The name of the constellation", "_type": "string" },
    +		"Cas": { "_title": "Cassiopeia", "_text": "The name of the constellation", "_type": "string" },
    +		"Cen": { "_title": "Centaurus", "_text": "The name of the constellation", "_type": "string" },
    +		"Cep": { "_title": "Cepheus", "_text": "The name of the constellation", "_type": "string" },
    +		"Cet": { "_title": "Cetus", "_text": "The name of the constellation", "_type": "string" },
    +		"Cha": { "_title": "Chamaeleon", "_text": "The name of the constellation", "_type": "string" },
    +		"Cir": { "_title": "Circinus", "_text": "The name of the constellation", "_type": "string" },
    +		"Col": { "_title": "Columba", "_text": "The name of the constellation", "_type": "string" },
    +		"Com": { "_title": "Coma Berenices", "_text": "The name of the constellation", "_type": "string" },
    +		"CrA": { "_title": "Corona\nAustrina", "_text": "The name of the constellation", "_type": "string" },
    +		"CrB": { "_title": "Corona\nBorealis", "_text": "The name of the constellation", "_type": "string" },
    +		"Crv": { "_title": "Corvus", "_text": "The name of the constellation", "_type": "string" },
    +		"Crt": { "_title": "Crater", "_text": "The name of the constellation", "_type": "string" },
    +		"Cru": { "_title": "Crux", "_text": "The name of the constellation", "_type": "string" },
    +		"Cyg": { "_title": "Cygnus", "_text": "The name of the constellation", "_type": "string" },
    +		"Del": { "_title": "Delphinus", "_text": "The name of the constellation", "_type": "string" },
    +		"Dor": { "_title": "Dorado", "_text": "The name of the constellation", "_type": "string" },
    +		"Dra": { "_title": "Draco", "_text": "The name of the constellation", "_type": "string" },
    +		"Equ": { "_title": "Equuleus", "_text": "The name of the constellation", "_type": "string" },
    +		"Eri": { "_title": "Eridanus", "_text": "The name of the constellation", "_type": "string" },
    +		"For": { "_title": "Fornax", "_text": "The name of the constellation", "_type": "string" },
    +		"Gem": { "_title": "Gemini", "_text": "The name of the constellation", "_type": "string" },
    +		"Gru": { "_title": "Grus", "_text": "The name of the constellation", "_type": "string" },
    +		"Her": { "_title": "Hercules", "_text": "The name of the constellation", "_type": "string" },
    +		"Hor": { "_title": "Horologium", "_text": "The name of the constellation", "_type": "string" },
    +		"Hya": { "_title": "Hydra", "_text": "The name of the constellation", "_type": "string" },
    +		"Hyi": { "_title": "Hydrus", "_text": "The name of the constellation", "_type": "string" },
    +		"Ind": { "_title": "Indus", "_text": "The name of the constellation", "_type": "string" },
    +		"Lac": { "_title": "Lacerta", "_text": "The name of the constellation", "_type": "string" },
    +		"Leo": { "_title": "Leo", "_text": "The name of the constellation", "_type": "string" },
    +		"LMi": { "_title": "Leo Minor", "_text": "The name of the constellation", "_type": "string" },
    +		"Lep": { "_title": "Lepus", "_text": "The name of the constellation", "_type": "string" },
    +		"Lib": { "_title": "Libra", "_text": "The name of the constellation", "_type": "string" },
    +		"Lup": { "_title": "Lupus", "_text": "The name of the constellation", "_type": "string" },
    +		"Lyn": { "_title": "Lynx", "_text": "The name of the constellation", "_type": "string" },
    +		"Lyr": { "_title": "Lyra", "_text": "The name of the constellation", "_type": "string" },
    +		"Men": { "_title": "Mensa", "_text": "The name of the constellation", "_type": "string" },
    +		"Mic": { "_title": "Microscopium", "_text": "The name of the constellation", "_type": "string" },
    +		"Mon": { "_title": "Monoceros", "_text": "The name of the constellation", "_type": "string" },
    +		"Mus": { "_title": "Musca", "_text": "The name of the constellation", "_type": "string" },
    +		"Nor": { "_title": "Norma", "_text": "The name of the constellation", "_type": "string" },
    +		"Oct": { "_title": "Octans", "_text": "The name of the constellation", "_type": "string" },
    +		"Oph": { "_title": "Ophiuchus", "_text": "The name of the constellation", "_type": "string" },
    +		"Ori": { "_title": "Orion", "_text": "The name of the constellation", "_type": "string" },
    +		"Pav": { "_title": "Pavo", "_text": "The name of the constellation", "_type": "string" },
    +		"Peg": { "_title": "Pegasus", "_text": "The name of the constellation", "_type": "string" },
    +		"Per": { "_title": "Perseus", "_text": "The name of the constellation", "_type": "string" },
    +		"Phe": { "_title": "Phoenix", "_text": "The name of the constellation", "_type": "string" },
    +		"Pic": { "_title": "Pictor", "_text": "The name of the constellation", "_type": "string" },
    +		"Psc": { "_title": "Pisces", "_text": "The name of the constellation", "_type": "string" },
    +		"PsA": { "_title": "Piscis Austrinus", "_text": "The name of the constellation", "_type": "string" },
    +		"Pup": { "_title": "Puppis", "_text": "The name of the constellation", "_type": "string" },
    +		"Pyx": { "_title": "Pyxis", "_text": "The name of the constellation", "_type": "string" },
    +		"Ret": { "_title": "Reticulum", "_text": "The name of the constellation", "_type": "string" },
    +		"Sge": { "_title": "Sagitta", "_text": "The name of the constellation", "_type": "string" },
    +		"Sgr": { "_title": "Sagittarius", "_text": "The name of the constellation", "_type": "string" },
    +		"Sco": { "_title": "Scorpius", "_text": "The name of the constellation", "_type": "string" },
    +		"Scl": { "_title": "Sculptor", "_text": "The name of the constellation", "_type": "string" },
    +		"Sct": { "_title": "Scutum", "_text": "The name of the constellation", "_type": "string" },
    +		"Ser": { "_title": "Serpens", "_text": "The name of the constellation", "_type": "string" },
    +		"Sex": { "_title": "Sextans", "_text": "The name of the constellation", "_type": "string" },
    +		"Tau": { "_title": "Taurus", "_text": "The name of the constellation", "_type": "string" },
    +		"Tel": { "_title": "Telescopium", "_text": "The name of the constellation", "_type": "string" },
    +		"Tri": { "_title": "Triangulum", "_text": "The name of the constellation", "_type": "string" },
    +		"TrA": { "_title": "Triangulum\nAustrale", "_text": "The name of the constellation", "_type": "string" },
    +		"Tuc": { "_title": "Tucana", "_text": "The name of the constellation", "_type": "string" },
    +		"UMa": { "_title": "Ursa Major", "_text": "The name of the constellation", "_type": "string" },
    +		"UMi": { "_title": "Ursa Minor", "_text": "The name of the constellation", "_type": "string" },
    +		"Vel": { "_title": "Vela", "_text": "The name of the constellation", "_type": "string" },
    +		"Vir": { "_title": "Virgo", "_text": "The name of the constellation", "_type": "string" },
    +		"Vol": { "_title": "Volans", "_text": "The name of the constellation", "_type": "string" },
    +		"Vul": { "_title": "Vulpecula", "_text": "The name of the constellation", "_type": "string" }
    +	},
    +	"planets": {
    +		"_title": "Planets",
    +		"Me": { "_title": "Mercury", "_text": "The word for Mercury", "_type": "string" },
    +		"V": { "_title": "Venus", "_text": "The word for Venus", "_type": "string" },
    +		"Ma": { "_title": "Mars", "_text": "The word for Mars", "_type": "string" },
    +		"J": { "_title": "Jupiter", "_text": "The word for Jupiter", "_type": "string" },
    +		"S": { "_title": "Saturn", "_text": "The word for Saturn", "_type": "string" },
    +		"U": { "_title": "Uranus", "_text": "The word for Uranus", "_type": "string" },
    +		"N": { "_title": "Neptune", "_text": "The word for Neptune", "_type": "string" }
    +	},
    +	"projections": {
    +		"_title": "Projections",
    +		"polar": { "_title": "Polar", "_text": "The name for a polar projection", "_type": "string" },
    +		"fisheye": { "_title": "Fisheye", "_text": "The name for a fisheye projection", "_type": "string" },
    +		"ortho": { "_title": "Orthographic", "_text": "The name for an orthographic projection", "_type": "string" },
    +		"stereo": { "_title": "Stereographic", "_text": "The name for a stereographic projection", "_type": "string" },
    +		"lambert": { "_title": "Lambert", "_text": "The name for a Lambert projection", "_type": "string" },
    +		"gnomic": { "_title": "Gnomic", "_text": "The name for a Gnomic projection", "_type": "string" },
    +		"equirectangular": { "_title": "Equirectangular", "_text": "The name for an equirectangular projection", "_type": "string" },
    +		"mollweide": { "_title": "Mollweide", "_text": "The name for a Mollweide projection", "_type": "string" },
    +		"planechart": { "_title": "Planechart", "_text": "The name for a planechart projection", "_type": "string" }
    +	},
    +	"starnames": {
    +		"_title": "Star names",
    +		"7588": { "_title": "Achernar", "_text": "The name of the star Achernar", "_type": "string" },
    +		"11767": { "_title": "Polaris", "_text": "The name of the star Polaris", "_type": "string" },
    +		"21421": { "_title": "Aldebaran", "_text": "The name of the star Aldebaran", "_type": "string" },
    +		"24436": { "_title": "Rigel", "_text": "The name of the star Rigel", "_type": "string" },
    +		"24608": { "_title": "Capella", "_text": "The name of the star Capella", "_type": "string" },
    +		"27989": { "_title": "Betelgeuse", "_text": "The name of the star Betelgeuse", "_type": "string" },
    +		"30438": { "_title": "Canopus", "_text": "The name of the star Canopus", "_type": "string" },
    +		"32349": { "_title": "Sirius", "_text": "The name of the star Sirius", "_type": "string" },
    +		"33579": { "_title": "Adara", "_text": "The name of the star Adara", "_type": "string" },
    +		"37279": { "_title": "Procyon", "_text": "The name of the star Procyon", "_type": "string" },
    +		"37826": { "_title": "Pollux", "_text": "The name of the star Pollux", "_type": "string" },
    +		"49669": { "_title": "Regulus", "_text": "The name of the star Regulus", "_type": "string" },
    +		"62434": { "_title": "Mimosa", "_text": "The name of the star Mimosa", "_type": "string" },
    +		"65378": { "_title": "Mizar", "_text": "The name of the star Mizar", "_type": "string" },
    +		"65474": { "_title": "Spica", "_text": "The name of the star Spica", "_type": "string" },
    +		"68702": { "_title": "Hadar", "_text": "The name of the star Hadar", "_type": "string" },
    +		"69673": { "_title": "Arcturus", "_text": "The name of the star Arcturus", "_type": "string" },
    +		"71683": { "_title": "Alpha Centauri A", "_text": "The name of the star Alpha Centauri A", "_type": "string" },
    +		"80763": { "_title": "Antares", "_text": "The name of the star Antares", "_type": "string" },
    +		"85927": { "_title": "Shaula", "_text": "The name of the star Shaula", "_type": "string" },
    +		"91262": { "_title": "Vega", "_text": "The name of the star Vega", "_type": "string" },
    +		"97649": { "_title": "Altair", "_text": "The name of the star Altair", "_type": "string" },
    +		"102098": { "_title": "Deneb", "_text": "The name of the star Deneb", "_type": "string" },
    +		"113368": { "_title": "Fomalhaut", "_text": "The name of the star Fomalhaut", "_type": "string" }
    +	},
    +	"sun": { "_title": "Sun", "_text": "The word for the Sun", "_type": "string" },
    +	"moon": { "_title": "Moon", "_text": "The word of the Moon", "_type": "string" },
    +	"date": { "_title": "", "_text": "Date &amp; Time", "_type": "string" },
    +	"datechange": { "_title": "", "_text": "Change the date/time (shown in your local time)", "_type": "string" },
    +	"close": { "_title": "", "_text": "close", "_type": "string" },
    +	"position": { "_title": "Position", "_text": "Label for the latitude &amp; longitude (in the dialog that appears when the user clicks on the position)", "_type": "string" },
    +	"positionchange": { "_title": "Change Position", "_text": "Hint text to change the longitude/latitude", "_type": "string" },
    +	"N": { "_title": "North", "_text": "North label", "_type": "string" },
    +	"E": { "_title": "East", "_text": "East label", "_type": "string" },
    +	"S": { "_title": "South", "_text": "South label", "_type": "string" },
    +	"W": { "_title": "West", "_text": "West label", "_type": "string" },
    +	"keyboard": { "_title": "", "_text": "Keyboard shortcuts:", "_type": "string" },
    +	"fast": { "_title": "", "_text": "increase time speed", "_type": "string" },
    +	"stop": { "_title": "", "_text": "set time rate to zero", "_type": "string" },
    +	"slow": { "_title": "", "_text": "decrease time speed", "_type": "string" },
    +	"reset": { "_title": "", "_text": "set time to now", "_type": "string" },
    +	"cardinal": { "_title": "", "_text": "toggle cardinal points", "_type": "string" },
    +	"stars": { "_title": "", "_text": "toggle stars", "_type": "string" },
    +	"starlabels": { "_title": "", "_text": "toggle star labels", "_type": "string" },
    +	"neg": { "_title": "", "_text": "invert colours", "_type": "string" },
    +	"atmos": { "_title": "", "_text": "toggle atmosphere", "_type": "string" },
    +	"ground": { "_title": "", "_text": "toggle ground", "_type": "string" },
    +	"az": { "_title": "", "_text": "toggle Az/El gridlines", "_type": "string" },
    +	"eq": { "_title": "", "_text": "toggle Ra/Dec gridlines", "_type": "string" },
    +	"gal": { "_title": "", "_text": "toggle Galactic gridlines", "_type": "string" },
    +	"galaxy": { "_title": "", "_text": "toggle Galactic plane", "_type": "string" },
    +	"ec": { "_title": "", "_text": "toggle Ecliptic line", "_type": "string" },
    +	"meridian": { "_title": "", "_text": "toggle Meridian line", "_type": "string" },
    +	"con": { "_title": "", "_text": "toggle constellation lines", "_type": "string" },
    +	"conbound": { "_title": "", "_text": "toggle constellation boundaries", "_type": "string" },
    +	"names": { "_title": "", "_text": "toggle constellation names", "_type": "string" },
    +	"sol": { "_title": "", "_text": "toggle planets/Sun/Moon", "_type": "string" },
    +	"sollabels": { "_title": "", "_text": "toggle planet/Sun/Moon labels", "_type": "string" },
    +	"orbits": { "_title": "", "_text": "toggle planet orbits", "_type": "string" },
    +	"projection": { "_title": "", "_text": "change map projection", "_type": "string" },
    +	"meteorshowers": { "_title": "", "_text": "toggle meteor shower radiants", "_type": "string" },
    +	"addday": { "_title": "", "_text": "add 1 day", "_type": "string" },
    +	"subtractday": { "_title": "", "_text": "subtract 1 day", "_type": "string" },
    +	"addweek": { "_title": "", "_text": "add 1 week", "_type": "string" },
    +	"subtractweek": { "_title": "", "_text": "subtract 1 week", "_type": "string" },
    +	"azleft": { "_title": "", "_text": "rotate left", "_type": "string" },
    +	"azright": { "_title": "", "_text": "rotate right", "_type": "string" },
    +	"magup": { "_title": "", "_text": "increase magnitude limit", "_type": "string" },
    +	"magdown": { "_title": "", "_text": "decrease magnitude limit", "_type": "string" },
    +	"left" : { "_title": "", "_text": "&larr;", "_type": "string" },
    +	"right" : { "_title": "", "_text": "&rarr;", "_type": "string" },
    +	"up": { "_title": "", "_text": "&uarr;", "_type": "string" },
    +	"down": { "_title": "", "_text": "&darr;", "_type": "string" },
    +	"power": { "_title": "", "_text": "Powered by LCO", "_type": "string" }
    +}
    diff --git a/html/allsky/virtualsky/lang/nl.json b/html/allsky/virtualsky/lang/nl.json
    new file mode 100644
    index 000000000..c5cb33388
    --- /dev/null
    +++ b/html/allsky/virtualsky/lang/nl.json
    @@ -0,0 +1,174 @@
    +{
    +	"title": "VirtualSky",
    +	"language": {
    +		"code": "nl",
    +		"name": "Nederlands",
    +		"alignment": "left",
    +		"translator": "Roland van Oorschot https://astroland.info"
    +	},
    +	"constellations": {
    +		"And": "Andromeda",
    +		"Ant": "Luchtpomp",
    +		"Aps": "Paradijsvogel",
    +		"Aqr": "Waterman",
    +		"Aql": "Arend",
    +		"Ara": "Altaar",
    +		"Ari": "Ram",
    +		"Aur": "Voerman",
    +		"Boo": "Ossenhoeder",
    +		"Cae": "Grafeerstift",
    +		"Cam": "Giraffe",
    +		"Cnc": "Kreeft",
    +		"CVn": "Jachthonden",
    +		"CMa": "Grote Hond",
    +		"CMi": "Kleine Hond",
    +		"Cap": "Steenbok",
    +		"Car": "Kiel",
    +		"Cas": "Cassiopeia",
    +		"Cen": "Centaur",
    +		"Cep": "Cepheus",
    +		"Cet": "Walvis",
    +		"Cha": "Kameleon",
    +		"Cir": "Passer",
    +		"Col": "Duif",
    +		"Com": "Haar van Berenice",
    +		"CrA": "Zuiderkroon",
    +		"CrB": "Noorderkroon",
    +		"Crv": "Raaf",
    +		"Crt": "Beker",
    +		"Cru": "Zuiderkruis",
    +		"Cyg": "Zwaan",
    +		"Del": "Dolfijn",
    +		"Dor": "Zwaardvis",
    +		"Dra": "Draak",
    +		"Equ": "Veulen",
    +		"Eri": "Eridanus",
    +		"For": "Oven",
    +		"Gem": "Tweelingen",
    +		"Gru": "Kraanvogel",
    +		"Her": "Hercules",
    +		"Hor": "Slingeruurwerk",
    +		"Hya": "Waterslang",
    +		"Hyi": "Kleine Waterslang",
    +		"Ind": "Indiaan",
    +		"Lac": "Hagedis",
    +		"Leo": "Leeuw",
    +		"LMi": "Kleine Leeuw",
    +		"Lep": "Haas",
    +		"Lib": "Weegschaal",
    +		"Lup": "Wolf",
    +		"Lyn": "Lynx",
    +		"Lyr": "Lier",
    +		"Men": "Tafelberg",
    +		"Mic": "Microscoop",
    +		"Mon": "Eenhoorn",
    +		"Mus": "Vlieg",
    +		"Nor": "Winkelhaak",
    +		"Oct": "Octans",
    +		"Oph": "Slangendrager",
    +		"Ori": "Orion",
    +		"Pav": "Pauw",
    +		"Peg": "Pegasus",
    +		"Per": "Perseus",
    +		"Phe": "Phoenix",
    +		"Pic": "Schilder",
    +		"Psc": "Vissen",
    +		"PsA": "Zuidervis",
    +		"Pup": "Achtersteven",
    +		"Pyx": "Kompas",
    +		"Ret": "Net",
    +		"Sge": "Pijl",
    +		"Sgr": "Boogschutter",
    +		"Sco": "Schorpioen",
    +		"Scl": "Beeldhouwer",
    +		"Sct": "Schild",
    +		"Ser": "Slang",
    +		"Sex": "Sextant",
    +		"Tau": "Stier",
    +		"Tel": "Telescoop",
    +		"Tri": "Driehoek",
    +		"TrA": "Zuiderdriehoek",
    +		"Tuc": "Toekan",
    +		"UMa": "Grote Beer",
    +		"UMi": "Kleine Beer",
    +		"Vel": "Zeilen",
    +		"Vir": "Maagd",
    +		"Vol": "Vliegende Vis",
    +		"Vul": "Vosje"
    +	},
    +	"planets": {
    +		"Me": "Mercurius",
    +		"V": "Venus",
    +		"Ma": "Mars",
    +		"J": "Jupiter",
    +		"S": "Saturnus",
    +		"U": "Uranus",
    +		"N": "Neptunus"
    +	},
    +	"sun":"Zon",
    +	"moon":"Maan",
    +	"date": "Datum &amp; Tijd",
    +	"datechange": "Pas datum/tijd aan (lokale tijd)",
    +	"close": "sluit",
    +	"position": "Lengtegraad &amp; Breedtegraad",
    +	"positionchange": "Pas de lengtegraad/breedtegraad aan",
    +	"N": "N",
    +	"E": "O",
    +	"S": "Z",
    +	"W": "W",
    +	"keyboard": "Toetsenbord snelkoppelingen:",
    +	"fast": "versnel tijd",
    +	"stop": "zet de tijdsnelheid op nul",
    +	"slow": "vertraag tijd",
    +	"reset": "zet de tijd op nu",
    +	"cardinal": "schakel cardinale punten aan/uit",
    +	"stars": "schakel sterren aan/uit",
    +	"starlabels": "schakel ster labels aan/uit",
    +	"neg": "inverteer kleuren",
    +	"atmos": "schakel atmosfeer aan/uit",
    +	"ground": "schakel grond aan/uit",
    +	"az": "schakel Az/El rasterlijnen aan/uit",
    +	"eq": "schakel Ra/Dec rasterlijnen aan/uit",
    +	"gal": "schakel Galactische rasterlijnen aan/uit",
    +	"galaxy": "schakel Galactisch vlak aan/uit",
    +	"ec": "schakel Ecliptica aan/uit",
    +	"meridian": "schakel Meridiaanlijn aan/uit",
    +	"con": "schakel sterrenbeeldlijnen aan/uit",
    +	"conbound": "schakel sterrenbeeld grenzen aan/uit",
    +	"names": "schakel sterrenbeeld namen aan/uit",
    +	"sol": "schakel planeten/Zon/Maan aan/uit",
    +	"sollabels": "schakel planeet/Zon/Maan labels aan/uit",
    +	"orbits": "schakel planeetbanen aan/uit",
    +	"projection":"pas kaartprojectie aan",
    +	"meteorshowers":"schakel meteoorregen radianten aan/uit",
    +	"addday": "verhoog met 1 dag",
    +	"subtractday": "verlaag met 1 dag",
    +	"addweek": "verhoog met 1 week",
    +	"subtractweek": "verlaag met 1 week",
    +	"azleft": "roteer links",
    +	"azright": "roteer rechts",
    +	"magup": "verhoog magnitude limiet",
    +	"magdown": "verlaag magnitude limiet",
    +	"left" : "&larr;",
    +	"right" : "&rarr;",
    +	"up": "&uarr;",
    +	"down": "&darr;",
    +	"power": "Mogelijk gemaakt door LCO",
    +	"projections": {
    +		"polar": "Polaire projectie",
    +		"fisheye": "Vissenoog projectie",
    +		"ortho": "Orthografische projectie",
    +		"stereo": "Stereo projectie",
    +		"lambert": "Lambert projectie",
    +		"gnomic": "Gnomische projectie",
    +		"equirectangular": "Equirectangulaire projectie",
    +		"mollweide": "Mollweide projectie",
    +		"planechart": "Planechart projectie"
    +	},
    +	"starnames":{
    +		"7588":"Achernar","11767":"Polaris","21421":"Aldebaran","24436":"Rigel","24608":"Capella","27989":"Betelgeuze",
    +		"30438":"Canopus","32349":"Sirius","33579":"Adara","37279":"Procyon","37826":"Pollux","49669":"Regulus","62434":"Mimosa",
    +		"65378":"Mizar","65474":"Spica","68702":"Hadar","69673":"Arcturus","71683":"Alpha Centauri A","80763":"Antares","85927":"Shaula",
    +		"91262":"Vega","97649":"Altair","102098":"Deneb","113368":"Fomalhaut"
    +	}
    +}
    diff --git a/html/allsky/virtualsky/lang/pl.json b/html/allsky/virtualsky/lang/pl.json
    new file mode 100644
    index 000000000..77173a90a
    --- /dev/null
    +++ b/html/allsky/virtualsky/lang/pl.json
    @@ -0,0 +1,174 @@
    +{
    +	"title": "VirtualSky",
    +	"language": {
    +		"code": "pl",
    +		"name": "Polski",
    +		"alignment": "left",
    +		"translator": "raajon"
    +	},
    +	"constellations": {
    +		"And": "Andromeda",
    +		"Ant": "Pompa",
    +		"Aps": "Ptak Rajski",
    +		"Aqr": "Wodnik",
    +		"Aql": "Orzel",
    +		"Ara": "Oltarz",
    +		"Ari": "Baran",
    +		"Aur": "Woznica",
    +		"Boo": "Wolarz",
    +		"Cae": "Rylec",
    +		"Cam": "Zyrafa(Wielblad)",
    +		"Cnc": "Rak",
    +		"CVn": "Psy Goncze",
    +		"CMa": "Wielki Pies",
    +		"CMi": "Maly Pies",
    +		"Cap": "Koziorozec",
    +		"Car": "Kil",
    +		"Cas": "Kasjopeja",
    +		"Cen": "Centaur",
    +		"Cep": "Cefeusz",
    +		"Cet": "Wieloryb",
    +		"Cha": "Kameleon",
    +		"Cir": "Cyrkiel",
    +		"Col": "Golab",
    +		"Com": "Warkocz Bereniki",
    +		"CrA": "Korona Poludniowa",
    +		"CrB": "Korona Pólnocna",
    +		"Crv": "Kruk",
    +		"Crt": "Puchar",
    +		"Cru": "Krzyz Poludnia",
    +		"Cyg": "Labedz",
    +		"Del": "Delfin",
    +		"Dor": "Zlota Ryba",
    +		"Dra": "Smok",
    +		"Equ": "Zrebie",
    +		"Eri": "Erydan",
    +		"For": "Piec",
    +		"Gem": "Bliznieta",
    +		"Gru": "Zuraw",
    +		"Her": "Herkules",
    +		"Hor": "Zegar",
    +		"Hya": "Hydra",
    +		"Hyi": "Waz Wodny",
    +		"Ind": "Indianin",
    +		"Lac": "Jaszczurka",
    +		"Leo": "Lew",
    +		"LMi": "Maly Lew",
    +		"Lep": "Zajac",
    +		"Lib": "Waga",
    +		"Lup": "Wilk",
    +		"Lyn": "Rys",
    +		"Lyr": "Lutnia",
    +		"Men": "Góra Stolowa",
    +		"Mic": "Mikroskop",
    +		"Mon": "Jednorozec",
    +		"Mus": "Mucha",
    +		"Nor": "Wegielnica",
    +		"Oct": "Oktant",
    +		"Oph": "Wezownik",
    +		"Ori": "Orion",
    +		"Pav": "Paw",
    +		"Peg": "Pegaz",
    +		"Per": "Perseusz",
    +		"Phe": "Feniks",
    +		"Pic": "Malarz",
    +		"Psc": "Ryby",
    +		"PsA": "Ryba Poludniowa",
    +		"Pup": "Rufa",
    +		"Pyx": "Kompas",
    +		"Ret": "Siec",
    +		"Sge": "Strzala",
    +		"Sgr": "Strzelec",
    +		"Sco": "Skorpion",
    +		"Scl": "Rzezbiarz",
    +		"Sct": "Tarcza Sobieskiego",
    +		"Ser": "Waz",
    +		"Sex": "Sekstant",
    +		"Tau": "Byk",
    +		"Tel": "Luneta",
    +		"Tri": "Trójkat",
    +		"TrA": "Trójkat Poludniowy",
    +		"Tuc": "Tukan",
    +		"UMa": "Wielka Niedzwiedzica",
    +		"UMi": "Mala Niedzwiedzica",
    +		"Vel": "Zagiel",
    +		"Vir": "Panna",
    +		"Vol": "Ryba Latajaca",
    +		"Vul": "Lisek"
    +	},
    +	"planets": {
    +		"Me": "Merkury",
    +		"V": "Wenus",
    +		"Ma": "Mars",
    +		"J": "Jowisz",
    +		"S": "Saturn",
    +		"U": "Uran",
    +		"N": "Neptun"
    +	},
    +	"sun":"Slonce",
    +	"moon":"Ksiezyc",
    +	"date": "Data &amp; Czas",
    +	"datechange": "Zmien date / godzine (wyswietlane w Twoim czasie lokalnym)",
    +	"close": "close",
    +	"position": "Szerokosc &amp; Dlugosc",
    +	"positionchange": "Zmien dlugosc/szerokosc",
    +	"N": "Pn",
    +	"E": "Wsch.",
    +	"S": "Pd",
    +	"W": "Zach.",
    +	"keyboard": "Skróty klawiszy:",
    +	"fast": "przespiesz czas",
    +	"stop": "ustaw stope czasowa na zero",
    +	"slow": "zwolnij czas",
    +	"reset": "ustaw biezacy czas",
    +	"cardinal": "przelaczaj kierunki",
    +	"stars": "gwiazdy",
    +	"starlabels": "nazwy gwiazd",
    +	"neg": "odwróc kolory",
    +	"atmos": "atmosfera",
    +	"ground": "ziemia",
    +	"az": "linie siatki Az / El",
    +	"eq": "linie siatki Ra / Dec",
    +	"gal": "linie siatki galaktyki",
    +	"galaxy": "halo galaktyki",
    +	"ec": "linie siatki ekiptyki",
    +	"meridian": "linie siatki poludników",
    +	"con": "konstelacje",
    +	"conbound": "granice konstelacji",
    +	"names": "nazwy konstelacji",
    +	"sol": "Slonce / Ksiezyc",
    +	"sollabels": "ektykiety planet + Slónce i Ksiezyc",
    +	"orbits": "orbity planet",
    +	"projection":"projekcja",
    +	"meteorshowers":"roje meteorów",
    +	"addday": "dodaj dzien",
    +	"subtractday": "odejmij dzien",
    +	"addweek": "dodaj tydzien",
    +	"subtractweek": "odejmij tydzien",
    +	"azleft": "przekrec w lewo",
    +	"azright": "przekrec w prawo",
    +	"magup": "zwieksz limit magnitudy",
    +	"magdown": "zmniejsz limit magnitudy",
    +	"left" : "&larr;",
    +	"right" : "&rarr;",
    +	"up": "&uarr;",
    +	"down": "&darr;",
    +	"power": "Powered by LCO",
    +	"projections": {
    +		"polar": "Odwzorowanie Polarne",
    +		"fisheye": "Odwzorowanie Rybie Oko",
    +		"ortho": "Odwzorowanie prostokatne",
    +		"stereo": "Odwzorowanie Stereograficzne",
    +		"lambert": "Odwzorowanie Lamberta",
    +		"gnomic": "Odwzorowanie gnomiczne",
    +		"equirectangular": "Odwzorowanie walcowe",
    +		"mollweide": "Odwzorowanie Mollweidego",
    +		"planechart": "Odwzorowanie mapy samolotowej"
    +	},
    +	"starnames":{
    +		"7588":"Achernar","11767":"Polarna","21421":"Aldebaran","24436":"Rigel","24608":"Kapella","27989":"Betelgeza",
    +		"30438":"Kanopus","32349":"Syriusz","33579":"Adara","37279":"Procjon","37826":"Polluks","49669":"Regulus","62434":"Mimosa",
    +		"65378":"Mizar","65474":"Spica","68702":"Hadar","69673":"Arktur","71683":"Alpha Centauri A","80763":"Antares","85927":"Shaula",
    +		"91262":"Wega","97649":"Altair","102098":"Deneb","113368":"Fomalhaut"
    +	}
    +}
    diff --git a/html/allsky/virtualsky/lang/pt.json b/html/allsky/virtualsky/lang/pt.json
    new file mode 100755
    index 000000000..6f918c6b1
    --- /dev/null
    +++ b/html/allsky/virtualsky/lang/pt.json
    @@ -0,0 +1,174 @@
    +{
    +	"title": "VirtualSky",
    +	"language": {
    +		"code": "pt",
    +		"name": "Portugu&#234;s",
    +		"alignment": "left",
    +		"translator": "Eduardo Augusto Ramos"
    +	},
    +	"constellations": {
    +		"And": "Andr&#244;meda",
    +		"Ant": "M&#225;quina Pneum&#225;tica",
    +		"Aps": "Ave do Para&#237;so",
    +		"Aqr": "Aqu&#225;rio",
    +		"Aql": "&#193;guia",
    +		"Ara": "Altar",
    +		"Ari": "&#193;ries",
    +		"Aur": "Cocheiro",
    +		"Boo": "Boieiro",
    +		"Cae": "Cinzel",
    +		"Cam": "Girafa",
    +		"Cnc": "C&#226;ncer",
    +		"CVn": "C&#227;es de Ca&#231;a",
    +		"CMa": "C&#227;o Maior",
    +		"CMi": "C&#227;o Menor",
    +		"Cap": "Capric&#243;rnio",
    +		"Car": "Quilha",
    +		"Cas": "Cassiop&#233;ia",
    +		"Cen": "Centauro",
    +		"Cep": "Cefeu",
    +		"Cet": "Baleia",
    +		"Cha": "Camale&#227;o",
    +		"Cir": "Compasso",
    +		"Col": "Pomba",
    +		"Com": "Cabeleira de Berenice",
    +		"CrA": "Coroa do Sul",
    +		"CrB": "Coroa do Norte",
    +		"Crv": "Corvo",
    +		"Crt": "Ta&#231;a",
    +		"Cru": "Cruzeiro do Sul",
    +		"Cyg": "Cisne",
    +		"Del": "Delfim",
    +		"Dor": "Dourado",
    +		"Dra": "Drag&#227;o",
    +		"Equ": "Cavalo Menor",
    +		"Eri": "Er&#237;dano",
    +		"For": "Fornalha",
    +		"Gem": "G&#233;meos",
    +		"Gru": "Grou",
    +		"Her": "H&#233;rcules",
    +		"Hor": "Rel&#243;gio",
    +		"Hya": "Hidra F&#234;mea",
    +		"Hyi": "Hidra Macho",
    +		"Ind": "&#205;ndio",
    +		"Lac": "Lagarto",
    +		"Leo": "Le&#227;o",
    +		"LMi": "Le&#227;o Menor",
    +		"Lep": "Lebre",
    +		"Lib": "Balan&#231;a",
    +		"Lup": "Lobo",
    +		"Lyn": "Lince",
    +		"Lyr": "Lira",
    +		"Men": "Meseta",
    +		"Mic": "Microsc&#243;pio",
    +		"Mon": "Unic&#243;rnio",
    +		"Mus": "Musca",
    +		"Nor": "Esquadro",
    +		"Oct": "Oitante",
    +		"Oph": "Serpent&#225;rio",
    +		"Ori": "&#211;rion",
    +		"Pav": "Pav&#227;o",
    +		"Peg": "Cavalo Alado",
    +		"Per": "Perseu",
    +		"Phe": "F&#233;nix",
    +		"Pic": "Pintor",
    +		"Psc": "Peixes",
    +		"PsA": "Peixe Austral",
    +		"Pup": "Popa",
    +		"Pyx": "B&#250;ssola",
    +		"Ret": "Rede",
    +		"Sge": "Flecha",
    +		"Sgr": "Sagit&#225;rio",
    +		"Sco": "Escorpi&#227;o",
    +		"Scl": "Escultor",
    +		"Sct": "Escudo",
    +		"Ser": "Serpente",
    +		"Sex": "Sextante",
    +		"Tau": "Touro",
    +		"Tel": "Telesc&#243;pio",
    +		"Tri": "Tri&#226;ngulo",
    +		"TrA": "Tri&#226;ngulo\nAustral",
    +		"Tuc": "Tucano",
    +		"UMa": "Ursa Maior",
    +		"UMi": "Ursa Menor",
    +		"Vel": "Velame",
    +		"Vir": "Virgem",
    +		"Vol": "Peixe Voador",
    +		"Vul": "Raposa"
    +	},
    +	"planets": {
    +		"Me": "Merc&#250;rio",
    +		"V": "V&#234;nus",
    +		"Ma": "Marte",
    +		"J": "J&#250;piter",
    +		"S": "Saturno",
    +		"U": "Uranos",
    +		"N": "Netuno"
    +	},
    +	"sun":"Sol",
    +	"moon":"Lua",
    +	"date": "Data &amp; Tempo",
    +	"datechange": "Mudar data/tempo (mostrando no seu tempo local)",
    +	"close": "fechar",
    +	"position": "Latitude &amp; Longitude",
    +	"positionchange": "Mudar a longitude/latitude",
    +	"N": "N",
    +	"E": "L",
    +	"S": "S",
    +	"W": "O",
    +	"keyboard": "Atalhos do teclado:",
    +	"fast": "aumentar velocidade do tempo",
    +	"stop": "mudar taxa de tempo para zero",
    +	"slow": "diminuir velocidade do tempo",
    +	"reset": "mudar tempo para agora",
    +	"cardinal": "mostrar pontos cardeais",
    +	"stars": "mostrar estrelas",
    +	"starlabels": "mostrar nome das estrelas",
    +	"neg": "inverter cores",
    +	"atmos": "mostrar atmosfera",
    +	"ground": "mostrar ch&#227;o",
    +	"az": "mostrar Az/El linhas da grade",
    +	"eq": "mostrar Ra/Dec linhas da grade",
    +	"gal": "mostrar linhas da grade gal&#225;ctica",
    +	"galaxy": "mostrar superf&#237;cie gal&#225;ctica",
    +	"ec": "mostrar linha Ecl&#237;ptica",
    +	"meridian": "mostrar linha do Meridiano",
    +	"con": "mostrar linhas das constela&#231;&#245;es",
    +	"conbound": "mostrar fronteiras das constela&#231;&#245;es",
    +	"names": "mostrar nomes das constela&#231;&#245;es",
    +	"sol": "mostrar planetas/Sol/Lua",
    +	"sollabels": "mostrar nomes dos planetas/Sol/Lua",
    +	"orbits": "mostrar &#243;rbitas dos planetas",
    +	"projection":"mudar proje&#231;&#227;o do mapa",
    +	"meteorshowers":"mostrar toggle chuva de meteoros",
    +	"addday": "adicionar 1 dia",
    +	"subtractday": "retirar 1 dia",
    +	"addweek": "adicionar 1 semana",
    +	"subtractweek": "retirar 1 semana",
    +	"azleft": "girar para a esquerda",
    +	"azright": "girar para a direita",
    +	"magup": "aumentar magnitude",
    +	"magdown": "diminuir magnitude",
    +	"left" : "&larr;",
    +	"right" : "&rarr;",
    +	"up": "&uarr;",
    +	"down": "&darr;",
    +	"power": "Feito por LCOGT",
    +	"projections": {
    +		"polar": "Proje&#231;&#227;o polar",
    +		"fisheye": "Proje&#231;&#227;o olho-de-peixe",
    +		"ortho": "Proje&#231;&#227;o ortogr&#225;fica",
    +		"stereo": "Proje&#231;&#227;o estereogr&#225;fica",
    +		"lambert": "Proje&#231;&#227;o de Lambert",
    +		"gnomic": "Proje&#231;&#227;o de Gnomic",
    +		"equirectangular": "Proje&#231;&#227;o cil&#237;ndrica equidistante",
    +		"mollweide": "Proje&#231;&#227;o de Mollweide",
    +		"planechart": "Proje&#231;&#227;o planechart"
    +	},
    +	"starnames":{
    +		"7588":"Achernar","11767":"Polar","21421":"Aldebar&#227;","24436":"R&#237;gel","24608":"Capella","27989":"Betelgeuse",
    +		"30438":"Canopeia","32349":"S&#237;rio","33579":"Adara","37279":"Pr&#243;cion","37826":"P&#243;lux","49669":"R&#233;gulo","62434":"Mimosa",
    +		"65378":"Mizar","65474":"Espiga","68702":"Beta Centauri","69673":"Arcturo","71683":"Alpha Centauri A","80763":"Antares","85927":"Shaula",
    +		"91262":"Vega","97649":"Altair","102098":"Deneb","113368":"Fomalhaut"
    +	}
    +}
    \ No newline at end of file
    diff --git a/html/allsky/virtualsky/lang/translate.js b/html/allsky/virtualsky/lang/translate.js
    new file mode 100755
    index 000000000..d5424a5f0
    --- /dev/null
    +++ b/html/allsky/virtualsky/lang/translate.js
    @@ -0,0 +1,474 @@
    +/*
    +	Translation library v0.3
    +*/
    +(function(root){
    +
    +
    +	// Get the URL query string and parse it
    +	function getQuery() {
    +		var r = {length:0};
    +		var q = location.search;
    +		if(q && q != '#'){
    +			// remove the leading ? and trailing &
    +			q = q.replace(/^\?/,'').replace(/\&$/,'');
    +			q.split('&').forEach(function(element){
    +				var key = element.split('=')[0];
    +				var val = element.split('=')[1];
    +				if(/^[0-9.]+$/.test(val)) val = parseFloat(val);	// convert floats
    +				r[key] = val;
    +				r['length']++;
    +			});
    +		}
    +		return r;
    +	};
    +
    +	function Translator(inp){
    +
    +		this.q = getQuery();
    +		this.id = (inp && typeof inp.id==="string") ? inp.id : 'form';
    +		this.langfile = (inp && typeof inp.languages==="string") ? inp.languages : '';
    +		this.formfile = (inp && typeof inp.help==="string") ? inp.help : '';
    +		this.langs = (inp && typeof inp.langs==="object") ? inp.langs : { 'en': {'name':'English'} };
    +		// Set empty help and phrasebook
    +		this.form = undefined;
    +		this.phrasebook = undefined;
    +		this.logging = true;
    +		if(inp.callback) this.callback = inp.callback;
    +
    +		if(this.langfile) this.loadLanguages();
    +		else{
    +			for(var l in this.langs){
    +				if(this.langs[l]['default']) this.langdefault = l;
    +			}
    +		}
    +		this.loadHelp();
    +		
    +		return this;
    +	};
    +	
    +	Translator.prototype.loadHelp = function(){
    +		this.log('loadHelp',this.formfile);
    +		S(document).ajax(this.formfile,{
    +			'dataType': 'json',
    +			'this': this,
    +			'success': function(d,attr){
    +				this.form = d;
    +				this.init();
    +			},
    +			'error': function(err,attr){
    +				this.log('ERROR','Unable to load '+attr.url,err)
    +			}		
    +		});
    +		return this;
    +	};
    +
    +	Translator.prototype.loadLanguages = function(){
    +		this.log('loadLanguages',this.langfile);
    +		S(document).ajax(this.langfile,{
    +			'dataType': 'json',
    +			'this': this,
    +			'success': function(d,attr){
    +				this.langs = d;
    +				for(var l in this.langs){
    +					if(this.langs[l]['default']) this.langdefault = l;
    +				}
    +				this.init();
    +			},
    +			'error': function(err,attr){
    +				this.log('ERROR','Unable to load '+attr.url,err)
    +			}
    +		});
    +
    +
    +		return this;
    +	};
    +
    +	Translator.prototype.init = function(){
    +		this.log('init');
    +		if(!this.langdefault){
    +			this.log('ERROR','No default language set. Please make sure '+this.langfile+' has a default language set. Just add a %c"default": true%c','font-weight: bold;color:#0DBC37');
    +			return this;
    +		}
    +		
    +		// We need both input files (languages and the form) to continue
    +		if(!this.form || !this.langs) return this;
    +		
    +		// Load the master language config file
    +		this.setLanguage();
    +
    +		this.lang = this.q.lang;
    +		if(!this.lang) this.lang = "en";
    +		
    +		this.page = S('#'+this.id);
    +
    +		if(!this.langs[this.lang]){
    +			this.log('ERROR','The language '+this.lang+' does not appear to exist in the translation file.');
    +			this.page.html('The language '+this.lang+' does not appear to exist yet.');
    +			return this;
    +		}
    +
    +		html = "<form id=\"langchoice\"><label>Select language (not all are complete):</label><select name=\"lang\">"
    +		for(var l in this.langs){
    +			html += '<option name="'+l+'" value="'+l+'"'+(this.lang==l ? " selected" : "")+'>'+sanitize(this.langs[l].name)+'</option>';
    +		}
    +		html += "</select> <button id=\"newlang\">Create new language</button></form>";
    +
    +
    +		if(S('#translate_chooser').length == 0) this.page.prepend('<div id="translate_chooser"></div>');
    +		if(S('#translation').length == 0) this.page.append('<div id="translation"></div>')
    +		S('#translate_chooser').html(html).find('#langchoice select').on('change',{me:this},function(e){ e.data.me.setLanguage(e.currentTarget.value); });
    +
    +		S('#newlang').on('click',{me:this},function(e){
    +			e.preventDefault();
    +			var f = S('#translation input, #translation textarea, #translation select');
    +			for(var i = 0; i < f.length; i++) f[i].value = "";
    +			e.data.me.update();
    +		});
    +		this.setLanguage(this.lang);
    +
    +		return this;
    +	};
    +
    +	Translator.prototype.log = function(){
    +		if(this.logging || arguments[0]=="ERROR"){
    +			var args = Array.prototype.slice.call(arguments, 0);
    +			if(console && typeof console.log==="function"){
    +				if(arguments[0] == "ERROR") console.log('%cERROR%c %cTranslator%c: '+args[1],'color:white;background-color:#D60303;padding:2px;','','font-weight:bold;','',(args.length > 2 ? args.splice(2):""));
    +				else if(arguments[0] == "WARNING") console.log('%cWARNING%c %cTranslator%c: '+args[1],'color:white;background-color:#F9BC26;padding:2px;','','font-weight:bold;','',(args.length > 2 ? args.splice(2):""));
    +				else console.log('%cTranslator%c','font-weight:bold;','',args);
    +			}
    +		}
    +		return this;
    +	};
    +
    +	Translator.prototype.setLanguage = function(lang){
    +		this.log('setLanguage',lang)
    +		// If a language is provided, set it
    +		if(lang) this.lang = lang;
    +
    +		// Load the specified language
    +		this.loadLanguage(this.lang);
    +
    +		return this;
    +	};
    +
    +	Translator.prototype.loadLanguage = function(lang){
    +		this.log('loadLanguage',lang);
    +		if(!lang) lang = this.langdefault;
    +
    +		// Is the language already loaded?
    +		if(this.phrasebook && this.phrasebook[lang]){
    +			this.log('Already loaded '+this.phrasebook[lang].language.name+' ('+lang+')');
    +			return this.processLanguage(lang);
    +		}
    +
    +		// Set the loaded files counter for this language
    +		this.langs[lang].filesloaded = 0;
    +
    +		this.log('Loading file '+this.langs[lang].file);
    +		
    +		S(document).ajax(this.langs[lang].file,{
    +			dataType: 'json',
    +			this: this,
    +			lang: lang,
    +			error: function(err,attr){
    +				// We couldn't find this language so load the English version
    +				// so there is something to work from.
    +				this.log('ERROR',"Couldn't load "+attr.lang)
    +				if(attr.lang != "en") this.loadLanguage('en');
    +			},
    +			success: function(data,attr){
    +				// Increment the loaded file counter
    +				this.langs[attr.lang].filesloaded++;
    +				if(!this.phrasebook) this.phrasebook = {};
    +				if(!this.phrasebook[attr.lang]) this.phrasebook[attr.lang] = data;
    +				this.processLanguage(attr.lang);
    +			}
    +		});
    +
    +		return this;
    +	};
    +	
    +	Translator.prototype.processLanguage = function(lang){
    +		this.log('processLanguage',lang);
    +		
    +		if(lang){
    +			var hrefcat = S('a.langlinkcat').attr('href');
    +			S('a.langlinkcat').attr('href',hrefcat.substring(0,hrefcat.indexOf('?'))+'?lang='+this.phrasebook[lang].language.code);
    +			S('.langname').html(this.phrasebook[lang].language.name);
    +		}
    +
    +		this.rebuildForm();
    +
    +		return this;
    +	};
    +
    +	Translator.prototype.rebuildForm = function(){
    +		this.log('rebuildForm',this.phrasebook);
    +
    +		var html = "<form id=\"language\"></form>";
    +
    +		S('#translation').html(html);
    +		this.buildForm();
    +		
    +		S('#translation input, #translation textarea, #translation select').on('change',{me:this},function(e){
    +			e.data.me.update();
    +		});
    +
    +		this.update();
    +
    +		return this;
    +	};
    +
    +	Translator.prototype.update = function(){
    +		this.getOutput();
    +		this.percentComplete();
    +		var f = S('#translation input, #translation textarea, #translation select');
    +
    +		var dir = (this.phrasebook && this.phrasebook[this.lang] && this.phrasebook[this.lang].language.alignment) ? this.phrasebook[this.lang].language.alignment=="right" : "";
    +		if(S('#meta-alignment').length == 1) dir = S('#meta-alignment')[0].value;
    +		
    +		dir = (dir=="right" ? "rtl" : "ltr");
    +		f.attr('dir',dir);
    +		S('#translation').removeClass('ltr').removeClass('rtl').addClass(dir).attr('dir',dir);
    +		
    +		for(var i = 0; i < f.length; i++){
    +			if(f[i].value && S(f[i]).hasClass('error')) S(f[i]).removeClass('error').removeClass('blank');
    +			else if(!f[i].value) S(f[i]).addClass('error').addClass('blank');
    +		}
    +		return this;
    +	};
    +
    +	Translator.prototype.buildField = function(field,attr){
    +		if(!attr || !attr.key) return "";
    +		var id,d,p,cl,newk,ldef,inp,key,inpdef;
    +		key = attr.key;
    +		inp = "";
    +		ldef = this.phrasebook[this.langdefault].language.name;
    +		newk = safeKey(key);
    +		cl = sanitize((field._highlight ? "highlight" : ""));
    +		cl = sanitize((this.phrasebook && this.phrasebook[this.lang] && this.phrasebook[this.lang][key] ? cl : "blank error"));
    +		p = (attr.value || "");
    +		// Make new lines explicit
    +		if(p.indexOf("\n")>= 0) p = p.replace("\n","\\n");
    +		id = (attr.id || newk);
    +		id = "phrasebook-"+id;
    +		var inpdef = (attr['default'] || '');
    +		if(field._type=="textarea"){
    +			css = (field._height) ? ' style="height:'+field._height+'"' : "";
    +			inp = '<textarea class="'+cl+'" id="'+id+'" name="'+id+'"'+css+'>'+sanitize(p || (field._usedef ? inpdef : ""))+'</textarea>';
    +		}else if(field._type=="noedit"){
    +			inp = '<input type="hidden" id="'+id+'" name="'+id+'" value="'+sanitize(p)+'" />'+sanitize(p);
    +			inpdef = "";
    +		}else if(field._type=="select"){
    +			inp = '<select id="'+id+'" name="'+id+'">';
    +			for(var o = 0; o < field._options.length ; o++){
    +				var seldef = (d && field._options[o].value==d[key]) ? ' selected="selected"' : '';
    +				var sel = (p && field._options[o].value==p) ? ' selected="selected"' : (field._usedef) ? seldef : '';
    +				inp += '<option value="'+field._options[o].value+'"'+sel+'>'+field._options[o].name+'</option>'
    +				if(field._options[o].value == inpdef) inpdef = field._options[o].name;
    +			}
    +			inp += '</select>';
    +		}else if(field._type=="string"){
    +			inp = '<input type="text" class="'+cl+'" id="'+id+'" name="'+id+'" value="'+sanitize(p || (field._usedef ? inpdef : ""))+'" />';
    +		}
    +		return this.row((field._title ? field._title : key),field._text,inp,ldef,inpdef);
    +	}
    +	
    +	Translator.prototype.buildForm = function(){
    +
    +		var d,k,n,css;
    +		var html = "";
    +		var newk = "";
    +		var inp = "";
    +		var arr = false;
    +		var ldef = this.phrasebook[this.langdefault].language.name;
    +		var inpdef = "";
    +		k = "";
    +		done = {};
    +		var el = S('form#language');
    +		var id,subkey,def;
    +
    +		// Loop over the help file keys
    +		for(key in this.form){
    +			if(typeof this.form[key]==="object"){
    +				newk = safeKey(key);
    +				id = key;
    +				if(this.form[key]._text && this.form[key]._type){
    +					html += this.buildField(this.form[key],{'key':key,'id':id,'default':this.phrasebook[this.langdefault][key],'value':this.phrasebook[this.lang][key]});
    +					done[id] = true;
    +				}else{
    +				
    +					// If this section is a title
    +					if(this.form[key]._title){
    +						if(this.form[key]._level){
    +							l = this.form[key]._level;
    +							html += '<h'+l+'>'+this.form[key]._title+'</h'+l+'>';
    +						}else{
    +							html += '<h2>'+this.form[key]._title+'</h2>';
    +						}
    +						if(this.form[key]._text){
    +							html += "	<div class=\"subt\">";
    +							html += "		<p>"+this.form[key]._text+"</p>";
    +							html += "	</div>";
    +						}
    +					
    +						//if(n >= 0) html += '<div class="group">';
    +					}
    +					if(this.form[key]){
    +						// Loop over properties processing this
    +						for(subkey in this.form[key]){
    +							if(this.form[key][subkey]){
    +								if(subkey.indexOf('_')!=0 && this.form[key][subkey]._text && this.form[key][subkey]._type){
    +									id = key+'-'+subkey;
    +									def = "";
    +									if(this.phrasebook[this.langdefault] && this.phrasebook[this.langdefault][key] && this.phrasebook[this.langdefault][key][subkey]) def = this.phrasebook[this.langdefault][key][subkey]+'';
    +									v = "";
    +									if(this.phrasebook[this.lang] && this.phrasebook[this.lang][key] && this.phrasebook[this.lang][key][subkey]) v = this.phrasebook[this.lang][key][subkey];
    +									html += this.buildField(JSON.parse(JSON.stringify(this.form[key][subkey])),{'key':subkey+'','id':id,'value':v,'default':def});
    +									done[id] = true;
    +								}
    +							}
    +						}
    +					}
    +				}
    +			}
    +		}
    +
    +		this.misc = {};
    +		// Loop over the default language keys
    +		for(key in this.phrasebook){
    +			if(this.phrasebook[key] && this.phrasebook[key][this.langdefault] && this.phrasebook[key][this.langdefault].value && !done[key]){
    +				this.misc[key] = true;
    +			}else{
    +				this.log('WARNING','Unable to set '+key);
    +			}
    +		}
    +
    +		if(this.misc){
    +			html += '<h2>Misc options</h2>';
    +			for(var f in this.misc){
    +				html += this.buildField({"_title":f,"_text":f,"_type":"string"},{'key':f,'value':this.phrasebook[this.lang][f],'default':this.phrasebook[this.langdefault][f]});
    +			}
    +		}
    +
    +		el.append(html);
    +		return this;
    +	};
    +
    +	Translator.prototype.percentComplete = function(){
    +		var percent = (100*this.count.done/this.count.total).toFixed(1);
    +		S('#progressbar .progress-inner').css({'width':percent+'%'});
    +		return this;
    +	};
    +
    +	Translator.prototype.row = function(title,desc,field,ldef,def){
    +		var id = field.indexOf("id=\"");
    +		id = field.substr(id+4);
    +		id = id.substr(0,id.indexOf("\""));
    +
    +		var html = "	<fieldset>";// id=\"fs"+id+"\">";
    +		html += "		<legend>"+title+"</legend>";
    +		html += "		<div class=\"twocol\">";
    +		html += "			<label for=\""+id+"\">"+desc+"</label>";
    +		html += "		</div>";
    +		html += "		<div class=\"fourcol\">";
    +		html += "			"+field;
    +		html += "			<div class=\"default\"><strong>"+ldef+" (default):</strong> "+def+"</div>";
    +		html += "		</div>";
    +		html += "	</fieldset>";
    +		return html;
    +	};
    +
    +	Translator.prototype.getOutput = function(){
    +	
    +		var output = {};
    +		var i,f,file,k,sk,key,subkey,val,css,out;
    +		this.count = { 'done': 0,'total': 0 };
    +		var lang = (S('#phrasebook-language-code')[0].value || this.lang);
    +
    +		if(S('#output').length == 0) S('#translation').after('<div id="output"></div>');
    +
    +		output = {'file':this.langs[this.langdefault].file.replace(new RegExp("(^|[^A-Za-z])"+this.langdefault+"([^A-Za-z])"),function(m,p1,p2){ return p1+lang+p2; }),'json':''};
    +		ojson = JSON.parse(JSON.stringify(this.phrasebook.en));
    +		k = 0;
    +		// Loop over every element and add it to an appropriate JSON for each output file
    +		for(key in this.phrasebook[this.langdefault]){
    +			if(k > 0) output.json += ',\n';
    +			if(typeof this.phrasebook[this.langdefault][key]==="string"){
    +				val = converter(S('#phrasebook-'+key)[0].value || "");
    +				output.json += '\t"'+key+'": "'+val+'"';
    +				ojson[key] = (S('#phrasebook-'+key)[0].value || "");
    +				if(val) this.count.done++;
    +				this.count.total++;
    +			}else{
    +				// Sub keys
    +				output.json +='\t"'+key+'\": {\n';
    +				sk = 0;
    +				for(subkey in this.phrasebook[this.langdefault][key]){
    +					val = converter(S('#phrasebook-'+key+'-'+subkey)[0].value || "");
    +					if(sk > 0) output.json += ',\n';
    +					output.json += '\t\t"'+subkey+'": "'+val+'"';
    +					ojson[key][subkey] = (S('#phrasebook-'+key+'-'+subkey)[0].value||"").replace(/\\n/g,"\n");
    +					if(val) this.count.done++;
    +					this.count.total++;
    +					sk++;
    +				}
    +				output.json += '\n\t}';
    +			}
    +			k++;
    +		}
    +	
    +		f = 0;
    +		S('#output').html('');
    +		json = '{\n'+output.json+'\n}';
    +		json = sanitize(json);
    +			
    +		css = (json) ? ' style="height:20em;overflow-x:hidden;font-family:monospace;"' : ''
    +		out = '<textarea onfocus="this.select()"'+css+' wrap="off">'+json+"</textarea>";
    +		
    +		if(typeof this.callback.update==="function") this.callback.update.call(this,{'json':ojson,'lang':lang});
    +
    +		var email;
    +		this.page.html().replace(/\(([a-zA-Z0-9\.\-]+) AT ([a-zA-Z0-9\.\-]+)\)/,function(m,p1,p2){
    +			email = p1+'@'+p2;
    +			return p1;
    +		});
    +		etxt = (S('.email a').length == 1) ? S('.email a').html() : S('.email').html();
    +		lang = S('#phrasebook-language-name')[0].value;
    +		S('.email').html('<a href="mailto:'+email+'?subject='+this.phrasebook.en.title+': '+lang+' translation&body='+encodeURI('Hi Chris,\n\nHere is an update to the '+lang+' translation.\n\nBest regards,\n\nNAME\n\n\n')+''+encodeURI(json)+'">'+etxt+'</a>')
    +		S('#output').append(out);
    +		S('.langfile').attr('href','https://github.com/slowe/VirtualSky/'+(this.langs[lang] ? 'edit':'new')+'/gh-pages/lang/'+output.file).html(output.file);
    +
    +		return this;
    +	};
    +
    +	function safeKey(k){
    +		return k.replace(/\./g,'-');
    +	}
    +
    +	function converter(tstr) {
    +		if(!tstr) return "";
    +		var bstr = '';
    +		for(var i=0; i<tstr.length; i++){
    +			if(tstr.charCodeAt(i)>127) bstr += '&amp;#' + tstr.charCodeAt(i) + ';';
    +			else bstr += tstr.charAt(i);
    +		}
    +		return bstr;
    +	}
    +	
    +	function sanitize(str){
    +		if(str && typeof str==="string"){
    +			str = str.replace(/</g,"&lt;");
    +			str = str.replace(/>/g,"&gt;");
    +			str = str.replace(/"/g,"&quot;");
    +			//str = str.replace(/\\n/g,"\\n");
    +		}
    +		return str;
    +	}
    +
    +	// Add CommonGround as a global variable
    +	root.Translator = Translator;
    +
    +})(window || this); // Self-closing function
    +
    diff --git a/html/allsky/virtualsky/lines_latin.json b/html/allsky/virtualsky/lines_latin.json
    new file mode 100755
    index 000000000..30a3d6e46
    --- /dev/null
    +++ b/html/allsky/virtualsky/lines_latin.json
    @@ -0,0 +1 @@
    +{ "lines": [["And",12.113,37.432,677,3092,3092,5447,9640,5447,5447,4436,4436,3881],["Ant",155,-34,51172,48926],["Aps",242.16,-75.3,72370,81065,81065,81852],["Aqr",340,-10,106278,109074,109074,110395,110395,110960,110960,111497,111497,112961,112961,114855,114855,115438,109074,110003,110003,109139,110003,111123,111123,112716,112716,113136,113136,114341,102618,106278],["Aql",295,3.4106,98036,97649,97649,97278,97649,95501,95501,97804,99473,97804,95501,93747,93747,93244,95501,93805],["Ara",260.62,-52,88714,85792,85792,83081,83081,82363,82363,85727,85727,85267,85267,85258,85258,88714],["Ari",39.538,20.792,13209,9884,9884,8903,8903,8832],["Aur",91.104,42.028,28380,28360,28360,24608,24608,23453,23453,23015,25428,23015,25428,28380],["Boo",220.66,31,71795,69673,69673,72105,72105,74666,74666,73555,73555,71075,71075,71053,71053,69673,69673,67927,67927,67459],["Cae",73,-38,21060,21770,21770,21861],["Cam",90,70,16228,18505,18505,22783,22783,17959,17959,16228,17959,25110],["Cnc",129.74,19.806,43103,42806,42806,40843,42806,42911,42911,40526,42911,44066],["CVn",196.74,40.102,61317,63125],["CMa",102.43,-22.14,33160,34045,34045,33347,33347,32349,32349,33977,33977,34444,34444,35037,35037,35904,33579,33856,33856,34444,33856,33165,33165,31592,31592,31416,31592,30324,31592,32349,33579,32759,30122,33579,33347,33160],["CMi",114.79,6.4269,37279,36188],["Cap",315,-21,100064,100345,100345,104139,104139,105515,105515,106985,106985,107556,105515,105881,105881,104139,100345,102485,104139,102978],["Car",130,-60,45238,50099,50099,52419,52419,52468,52468,54463,54463,53253,53253,51232,51232,50371,50371,45556,42568,41037,41037,30438,45080,45556,45080,42568,30438,31685,41037,39429],["Cas",19.788,62.184,8886,6686,6686,4427,4427,3179,3179,746],["Cen",196.07,-47.345,71683,68702,68702,66657,66657,68002,68002,68282,68282,67472,67472,67464,67464,65936,65936,65109,67464,68933,67472,71352,71352,73334,68002,61932,61932,60823,60823,59196,59196,56480,56480,56561],["Cep",340,71.008,109492,112724,112724,106032,106032,105199,105199,109492,112724,116727,116727,106032],["Cet",25.025,-7.1792,10324,11484,8102,3419,3419,1562,3419,5364,5364,6537,6537,8645,8645,11345,11345,12390,12390,12770,12770,11783,11783,8102,10826,12390,10826,12387,12387,12706,12706,14135,14135,13954,13954,12828,12828,11484,11484,12093,12093,12706],["Cha",160.38,-80,40702,51839,51839,60000],["Cir",225,-63,71908,75323,71908,74824],["Col",87.938,-35.094,30277,29807,29807,28199,28199,27628,27628,28328,27628,26634,26634,25859],["Com",191.82,23.306,64241,64394,64394,60742],["CrA",280,-41,91875,92989,92989,93174,93174,93825,93825,94114,94114,94160,94160,94005,94005,93542,93542,92953,91875,90887],["CrB",235,28.2,76127,75695,75695,76267,76267,76952,76952,77512,77512,78159,78159,78493],["Crv",186.63,-18.436,61174,60965,60965,59803,59803,59316,59316,59199,59316,61359,61359,60965],["Crt",170.94,-15.929,53740,54682,54682,55705,55705,55282,55282,53740,55282,55687,55687,56633,56633,58188,58188,57283,57283,55705],["Cru",186.75,-60.186,61084,60718,62434,59747],["Cyg",300.82,35.545,94779,95853,95853,97165,97165,100453,100453,102098,100453,102488,102488,104732,104732,107310,100453,98110,98110,95947],["Del",310.4,11.671,101421,101769,101769,101958,101958,102532,102532,102281,102281,101769],["Dor",80,-65,27100,27890,27890,26069,26069,27100,26069,21281,21281,19893],["Dra",250,60,87585,87833,87833,85670,85670,85829,85829,87585,87585,94376,94376,97433,97433,89937,89937,83895,83895,80331,80331,78527,78527,75458,75458,68756,68756,61281,61281,56211],["Equ",317.81,7.7581,104521,104858,104858,105570,105570,104987,104987,104521],["Eri",63,-28.756,7588,9007,9007,10602,10602,11407,11407,12413,12413,12486,12486,13847,13847,15510,15510,17797,17797,17874,17874,20042,20042,20535,20535,21393,21393,17651,17651,16611,16611,15474,15474,14146,14146,12843,12843,13701,13701,15197,15197,16537,16537,17378,17378,21444,21444,22109,22109,22701,22701,23875,23875,23972,23972,21594],["For",41.967,-31.634,13147,14879],["Gem",106.06,22.6,31681,34088,34088,35550,35550,35350,35350,32362,35550,36962,36962,37740,36962,37826,36962,36046,36046,34693,34693,36850,34693,33018,34693,32246,32246,30883,32246,30343,30343,29655,29655,28734],["Gru",336.85,-46.352,114131,110997,110997,109268,109268,112122,112122,114421,114421,114131,112122,113638,112122,112623,109268,109111,109111,108085],["Her",260.79,27.499,86414,87808,87808,85112,85112,84606,84606,84380,84380,81833,81833,81126,81126,79992,79992,77760,81833,81693,81693,80816,80816,80170,81693,83207,83207,85693,85693,84379,86974,87933,87933,88794,87933,86974,83207,84380],["Hor",49.138,-53.336,19747,12484,12484,14240],["Hya",154,-20,42799,42402,42402,42313,42313,43109,43109,43234,43234,42799,43234,43813,43813,45336,45336,46776,46776,46509,46509,46390,46390,48356,48356,49841,49841,51069,51069,52943,52943,56343,56343,57936,57936,64166,64166,64962],["Hyi",35.158,-69.956,2021,17678,17678,12394,12394,11001,11001,9236],["Ind",320,-55,105319,101772,101772,103227,103227,105319],["Lac",336.92,46.042,109937,111104,111104,111022,111022,110609,110609,110538,110538,111169,111169,111022],["Leo",160,13.139,57632,54879,54879,49669,49669,49583,49583,50583,50583,54872,54872,57632,50583,50335,50335,48455,48455,47908,54872,54879],["LMi",153.68,32.134,53229,51233,51233,49593,49593,46952,49593,53229],["Lep",83.487,-19.046,28910,28103,28103,27288,27288,25985,25985,24305,25985,27654,27654,27072,27072,25606,25606,23685,25985,25606,24305,24845,24305,24327,23685,24305,24327,24244,24845,24873],["Lib",227.99,-15.234,77853,76333,76333,74785,74785,72622,72622,73714,73714,76333],["Lup",232,-42,77634,78970,78970,78384,78384,77634,78384,76297,76297,75141,75141,75177,75141,73273,76297,76552,76552,74395,74395,71860,74395,71536,71860,70576,71860,73273],["Lyn",119.88,47.467,45860,45688,45688,44700,44700,44248,44248,41075,41075,36145,36145,33449,33449,30060],["Lyr",282.79,36.689,91262,91971,91971,92420,92420,93194,93194,92791,92791,91971],["Men",81.221,-77.504,25918,21949],["Mic",314.47,-36.275,105140,103738,103738,102831],["Mon",105.9,0.28194,29651,30867,30867,34769,34769,30419,30419,29151,34769,39863,39863,37447],["Mus",188.82,-70.161,62322,57363,57363,61199,61199,61585,61585,62322],["Nor",240,-51.351,79509,80000,80000,80582,80582,78639,78639,80000,78639,79509],["Oct",320,-85,107089,112405,112405,70638,70638,107089],["Oph",258,-4,86032,86742,84012,86742,86032,83000,83000,79882,79882,81377,81377,84012,84012,85755],["Ori",83.646,6,26727,26311,26311,25930,28691,29426,29426,29038,29038,27913,29426,28614,28614,27989,27989,26727,26727,27366,27366,24436,24436,25930,25930,25336,25336,26207,26207,27989,25336,22449,22449,22549,22549,22730,22730,23123,22449,22509,22509,22845,29038,28614],["Pav",294.18,-65.781,100751,105858,105858,102395,102395,99240,99240,100751,99240,98495,98495,91792,91792,93015,93015,99240,93015,92609,92609,90098,90098,88866,88866,92609,88866,86929],["Peg",340.46,19.466,1067,113963,113881,112158,112158,109352,113881,112748,112748,112440,112440,109176,109176,107354,113963,112447,112447,112029,112029,109427,109427,107315,677,113881,677,1067,113881,113963],["Per",55,45,17448,18246,18246,18614,18614,18532,18532,17358,17358,15863,15863,14328,14328,13268,15863,14576,14576,14354,14354,13254],["Phe",13.975,-48.581,5348,5165,5165,2072,2072,5348,5165,7083,7083,8837,8837,5165,5165,6867,6867,2072,2072,2081,2081,765,765,2072],["Pic",85.612,-53.474,32607,27530,27530,27321],["Psc",15,12,4889,5742,4889,6193,6193,5742,5742,7097,7097,8198,8198,9487,9487,8833,8833,7884,7884,7007,7007,4906,4906,3760,3760,1645,1645,118268,118268,116771,116771,116928,116928,115738,115738,114971,114971,115830,115830,116771],["PsA",334.27,-30.642,113368,111954,111954,108661,108661,107608,107608,109422,109422,111188,111188,113246],["Pup",118,-30,39757,38146,38146,35264,35264,31685,31685,32768,32768,36377,36377,39429,39429,39757],["Pyx",134.29,-27.352,42515,42828,42828,43409],["Ret",58.817,-59.998,19780,19921,19921,18597,18597,17440,17440,19780],["Sge",294.76,18.861,96837,97365,97365,96757,97365,98337,98337,98920],["Sgr",286.48,-28.477,89931,90496,89642,90185,90185,88635,88635,87072,88635,89931,89931,90185,90185,93506,93506,92041,92041,89931,92041,90496,90496,89341,93506,93864,93864,92855,92855,92041,92855,93085,93085,93683,93683,94820,94820,95168,93864,96406,96406,98688,98688,98412,98412,98032,98032,95347,98032,95294],["Sco",250,-35,85927,86670,86670,87073,87073,86228,86228,84143,84143,82671,82671,82514,82514,82396,82396,81266,81266,80763,80763,78401,80763,78265,80763,78820],["Scl",6.5667,-32.088,116231,4577,4577,115102,115102,116231],["Sct",280.1,-9.8886,92175,92202,92202,92814,92814,90595,90595,91117,91117,92175],["Ser",242,6.4,77516,77622,77622,77070,77070,76276,76276,77233,77233,78072,78072,77450,77450,77233,92946,89962,89962,86565,86565,86263,86263,84880],["Sex",154.07,-2.6144,51437,49641],["Tau",65,14.877,25428,21881,21881,20889,21421,26451,20205,20455,20205,18724,18724,15900,21421,20889,21421,20894,20894,20205,20889,20648,20648,20455,20455,17847],["Tel",289.88,-51.037,90568,90422],["Tri",30,32,10559,10064,10064,8796,8796,10559],["TrA",241.24,-65.388,82273,74946,74946,77952,77952,82273],["Tuc",0,-65,110130,114996,114996,1599,114996,2484],["UMa",160,50,67301,65378,65378,62956,62956,59774,59774,54061,54061,53910,53910,58001,58001,59774,58001,57399,57399,54539,54539,50372,54539,50801,53910,48402,48402,46853,46853,44471,46853,44127,48402,48319,48319,41704,41704,46733,46733,54061],["UMi",225,74,11767,85822,85822,82080,82080,77055,77055,79822,79822,75097,75097,72607,72607,77055],["Vel",143.66,-47.167,39953,42536,42536,42913,42913,45941,45941,48774,48774,52727,52727,51986,51986,50191,50191,46651,46651,44816,44816,39953],["Vir",201.1,-4.1583,57380,60030,60030,61941,61941,65474,65474,69427,69427,69701,69701,71957,65474,66249,66249,68520,68520,72220,66249,63090,63090,63608,63090,61941],["Vol",116.93,-69.801,37504,34481,34481,39794,39794,37504,39794,35228,39794,41312,41312,44382,44382,39794],["Vul",305,25,95771,98543]] }
    diff --git a/html/allsky/virtualsky/messier.json b/html/allsky/virtualsky/messier.json
    new file mode 100644
    index 000000000..349f8ad89
    --- /dev/null
    +++ b/html/allsky/virtualsky/messier.json
    @@ -0,0 +1,441 @@
    +[{
    +	"target": {"name": "M1", "alt":"" },
    +	"ra": { "decimal": 83.6330833, "h":"05", "m":"34", "s":"31.940" },
    +	"dec": { "decimal": 22.0145000, "d": "+22", "m":"00", "s":"52.200" }
    +},{
    +	"target": {"name": "M2", "alt":"" },
    +	"ra": { "decimal": 323.3625833, "h":"21", "m":"33", "s":"27.020" },
    +	"dec": { "decimal": -0.8232500, "d": "-00", "m":"49", "s":"23.700" }
    +},{
    +	"target": {"name": "M3", "alt":"" },
    +	"ra": { "decimal": 205.5484167, "h":"13", "m":"42", "s":"11.620" },
    +	"dec": { "decimal": 28.3772778, "d": "+28", "m":"22", "s":"38.200" }
    +},{
    +	"target": {"name": "M4", "alt":"" },
    +	"ra": { "decimal": 245.8967500, "h":"16", "m":"23", "s":"35.220" },
    +	"dec": { "decimal": -26.5257500, "d": "-26", "m":"31", "s":"32.700" }
    +},{
    +	"target": {"name": "M5", "alt":"" },
    +	"ra": { "decimal": 229.6384167, "h":"15", "m":"18", "s":"33.220" },
    +	"dec": { "decimal": 2.0810278, "d": "+02", "m":"04", "s":"51.700" }
    +},{
    +	"target": {"name": "M6", "alt":"" },
    +	"ra": { "decimal": 265.0833000, "h":"17", "m":"40", "s":"19.992" },
    +	"dec": { "decimal": -32.2533000, "d": "-32", "m":"15", "s":"11.880" }
    +},{
    +	"target": {"name": "M7", "alt":"" },
    +	"ra": { "decimal": 268.4625000, "h":"17", "m":"53", "s":"51.000" },
    +	"dec": { "decimal": -34.7933000, "d": "-34", "m":"47", "s":"35.880" }
    +},{
    +	"target": {"name": "M8", "alt":"" },
    +	"ra": { "decimal": 270.9042000, "h":"18", "m":"03", "s":"37.008" },
    +	"dec": { "decimal": -24.3867000, "d": "-24", "m":"23", "s":"12.120" }
    +},{
    +	"target": {"name": "M9", "alt":"" },
    +	"ra": { "decimal": 259.7990833, "h":"17", "m":"19", "s":"11.780" },
    +	"dec": { "decimal": -18.5162500, "d": "-18", "m":"30", "s":"58.500" }
    +},{
    +	"target": {"name": "M10", "alt":"" },
    +	"ra": { "decimal": 254.2877083, "h":"16", "m":"57", "s":"09.050" },
    +	"dec": { "decimal": -4.1003056, "d": "-04", "m":"06", "s":"01.100" }
    +},{
    +	"target": {"name": "M11", "alt":"" },
    +	"ra": { "decimal": 282.7708000, "h":"18", "m":"51", "s":"04.992" },
    +	"dec": { "decimal": -6.2700000, "d": "-06", "m":"16", "s":"12.000" }
    +},{
    +	"target": {"name": "M12", "alt":"" },
    +	"ra": { "decimal": 251.8090833, "h":"16", "m":"47", "s":"14.180" },
    +	"dec": { "decimal": -1.9485278, "d": "-01", "m":"56", "s":"54.700" }
    +},{
    +	"target": {"name": "M13", "alt":"" },
    +	"ra": { "decimal": 250.4234750, "h":"16", "m":"41", "s":"41.634" },
    +	"dec": { "decimal": 36.4613194, "d": "+36", "m":"27", "s":"40.750" }
    +},{
    +	"target": {"name": "M14", "alt":"" },
    +	"ra": { "decimal": 264.4006250, "h":"17", "m":"37", "s":"36.150" },
    +	"dec": { "decimal": -3.2459167, "d": "-03", "m":"14", "s":"45.300" }
    +},{
    +	"target": {"name": "M15", "alt":"" },
    +	"ra": { "decimal": 322.4930417, "h":"21", "m":"29", "s":"58.330" },
    +	"dec": { "decimal": 12.1670000, "d": "+12", "m":"10", "s":"01.200" }
    +},{
    +	"target": {"name": "M16", "alt":"" },
    +	"ra": { "decimal": 274.7000000, "h":"18", "m":"18", "s":"48.000" },
    +	"dec": { "decimal": -13.8067000, "d": "-13", "m":"48", "s":"24.120" }
    +},{
    +	"target": {"name": "M17", "alt":"" },
    +	"ra": { "decimal": 275.1958000, "h":"18", "m":"20", "s":"46.992" },
    +	"dec": { "decimal": -16.1717000, "d": "-16", "m":"10", "s":"18.120" }
    +},{
    +	"target": {"name": "M18", "alt":"" },
    +	"ra": { "decimal": 274.9917000, "h":"18", "m":"19", "s":"58.008" },
    +	"dec": { "decimal": -17.1017000, "d": "-17", "m":"06", "s":"06.120" }
    +},{
    +	"target": {"name": "M19", "alt":"" },
    +	"ra": { "decimal": 255.6570417, "h":"17", "m":"02", "s":"37.690" },
    +	"dec": { "decimal": -26.2679444, "d": "-26", "m":"16", "s":"04.600" }
    +},{
    +	"target": {"name": "M20", "alt":"" },
    +	"ra": { "decimal": 270.6750000, "h":"18", "m":"02", "s":"42.000" },
    +	"dec": { "decimal": -22.9717000, "d": "-22", "m":"58", "s":"18.120" }
    +},{
    +	"target": {"name": "M21", "alt":"" },
    +	"ra": { "decimal": 271.0542000, "h":"18", "m":"04", "s":"13.008" },
    +	"dec": { "decimal": -22.4900000, "d": "-22", "m":"29", "s":"24.000" }
    +},{
    +	"target": {"name": "M22", "alt":"" },
    +	"ra": { "decimal": 279.0997500, "h":"18", "m":"36", "s":"23.940" },
    +	"dec": { "decimal": -23.9047500, "d": "-23", "m":"54", "s":"17.100" }
    +},{
    +	"target": {"name": "M23", "alt":"" },
    +	"ra": { "decimal": 269.2667000, "h":"17", "m":"57", "s":"04.008" },
    +	"dec": { "decimal": -18.9850000, "d": "-18", "m":"59", "s":"06.000" }
    +},{
    +	"target": {"name": "M24", "alt":"" },
    +	"ra": { "decimal": 274.2000000, "h":"18", "m":"16", "s":"48.000" },
    +	"dec": { "decimal": -18.5500000, "d": "-18", "m":"33", "s":"00.000" }
    +},{
    +	"target": {"name": "M25", "alt":"" },
    +	"ra": { "decimal": 277.9458000, "h":"18", "m":"31", "s":"46.992" },
    +	"dec": { "decimal": -19.1167000, "d": "-19", "m":"07", "s":"00.120" }
    +},{
    +	"target": {"name": "M26", "alt":"" },
    +	"ra": { "decimal": 281.3250000, "h":"18", "m":"45", "s":"18.000" },
    +	"dec": { "decimal": -9.3833000, "d": "-09", "m":"22", "s":"59.880" }
    +},{
    +	"target": {"name": "M27", "alt":"" },
    +	"ra": { "decimal": 299.9015792, "h":"19", "m":"59", "s":"36.379" },
    +	"dec": { "decimal": 22.7210417, "d": "+22", "m":"43", "s":"15.750" }
    +},{
    +	"target": {"name": "M28", "alt":"" },
    +	"ra": { "decimal": 276.1370417, "h":"18", "m":"24", "s":"32.890" },
    +	"dec": { "decimal": -24.8698333, "d": "-24", "m":"52", "s":"11.400" }
    +},{
    +	"target": {"name": "M29", "alt":"" },
    +	"ra": { "decimal": 305.9833000, "h":"20", "m":"23", "s":"55.992" },
    +	"dec": { "decimal": 38.5233000, "d": "+38", "m":"31", "s":"23.880" }
    +},{
    +	"target": {"name": "M30", "alt":"" },
    +	"ra": { "decimal": 325.0921667, "h":"21", "m":"40", "s":"22.120" },
    +	"dec": { "decimal": -23.1798611, "d": "-23", "m":"10", "s":"47.500" }
    +},{
    +	"target": {"name": "M31", "alt":"" },
    +	"ra": { "decimal": 10.6847083, "h":"00", "m":"42", "s":"44.330" },
    +	"dec": { "decimal": 41.2687500, "d": "+41", "m":"16", "s":"07.500" }
    +},{
    +	"target": {"name": "M32", "alt":"" },
    +	"ra": { "decimal": 10.6742708, "h":"00", "m":"42", "s":"41.825" },
    +	"dec": { "decimal": 40.8651694, "d": "+40", "m":"51", "s":"54.610" }
    +},{
    +	"target": {"name": "M33", "alt":"" },
    +	"ra": { "decimal": 23.4621000, "h":"01", "m":"33", "s":"50.904" },
    +	"dec": { "decimal": 30.6599417, "d": "+30", "m":"39", "s":"35.790" }
    +},{
    +	"target": {"name": "M34", "alt":"" },
    +	"ra": { "decimal": 40.5208000, "h":"02", "m":"42", "s":"04.992" },
    +	"dec": { "decimal": 42.7617000, "d": "+42", "m":"45", "s":"42.120" }
    +},{
    +	"target": {"name": "M35", "alt":"" },
    +	"ra": { "decimal": 92.2250000, "h":"06", "m":"08", "s":"54.000" },
    +	"dec": { "decimal": 24.3333000, "d": "+24", "m":"19", "s":"59.880" }
    +},{
    +	"target": {"name": "M36", "alt":"" },
    +	"ra": { "decimal": 84.0750000, "h":"05", "m":"36", "s":"18.000" },
    +	"dec": { "decimal": 34.1400000, "d": "+34", "m":"08", "s":"24.000" }
    +},{
    +	"target": {"name": "M37", "alt":"" },
    +	"ra": { "decimal": 88.0750000, "h":"05", "m":"52", "s":"18.000" },
    +	"dec": { "decimal": 32.5533000, "d": "+32", "m":"33", "s":"11.880" }
    +},{
    +	"target": {"name": "M38", "alt":"" },
    +	"ra": { "decimal": 82.1792000, "h":"05", "m":"28", "s":"43.008" },
    +	"dec": { "decimal": 35.8550000, "d": "+35", "m":"51", "s":"18.000" }
    +},{
    +	"target": {"name": "M39", "alt":"" },
    +	"ra": { "decimal": 322.9500000, "h":"21", "m":"31", "s":"48.000" },
    +	"dec": { "decimal": 48.4333000, "d": "+48", "m":"25", "s":"59.880" }
    +},{
    +	"target": {"name": "M40", "alt":"" },
    +	"ra": { "decimal": 185.5522083, "h":"12", "m":"22", "s":"12.530" },
    +	"dec": { "decimal": 58.0829444, "d": "+58", "m":"04", "s":"58.600" }
    +},{
    +	"target": {"name": "M41", "alt":"" },
    +	"ra": { "decimal": 101.5042000, "h":"06", "m":"46", "s":"01.008" },
    +	"dec": { "decimal": -20.7567000, "d": "-20", "m":"45", "s":"24.120" }
    +},{
    +	"target": {"name": "M42", "alt":"" },
    +	"ra": { "decimal": 83.8220792, "h":"05", "m":"35", "s":"17.299" },
    +	"dec": { "decimal": -5.3911111, "d": "-05", "m":"23", "s":"28.000" }
    +},{
    +	"target": {"name": "M43", "alt":"" },
    +	"ra": { "decimal": 83.8792000, "h":"05", "m":"35", "s":"31.008" },
    +	"dec": { "decimal": -5.2700000, "d": "-05", "m":"16", "s":"12.000" }
    +},{
    +	"target": {"name": "M44", "alt":"" },
    +	"ra": { "decimal": 130.1000000, "h":"08", "m":"40", "s":"24.000" },
    +	"dec": { "decimal": 19.6667000, "d": "+19", "m":"40", "s":"00.120" }
    +},{
    +	"target": {"name": "M45", "alt":"" },
    +	"ra": { "decimal": 56.7500000, "h":"03", "m":"47", "s":"00.000" },
    +	"dec": { "decimal": 24.1167000, "d": "+24", "m":"07", "s":"00.120" }
    +},{
    +	"target": {"name": "M46", "alt":"" },
    +	"ra": { "decimal": 115.4417000, "h":"07", "m":"41", "s":"46.008" },
    +	"dec": { "decimal": -14.8100000, "d": "-14", "m":"48", "s":"36.000" }
    +},{
    +	"target": {"name": "M47", "alt":"" },
    +	"ra": { "decimal": 114.1458000, "h":"07", "m":"36", "s":"34.992" },
    +	"dec": { "decimal": -14.4833000, "d": "-14", "m":"28", "s":"59.880" }
    +},{
    +	"target": {"name": "M48", "alt":"" },
    +	"ra": { "decimal": 123.4292000, "h":"08", "m":"13", "s":"43.008" },
    +	"dec": { "decimal": -5.7500000, "d": "-05", "m":"45", "s":"00.000" }
    +},{
    +	"target": {"name": "M49", "alt":"" },
    +	"ra": { "decimal": 187.4449917, "h":"12", "m":"29", "s":"46.798" },
    +	"dec": { "decimal": 8.0004111, "d": "+08", "m":"00", "s":"01.480" }
    +},{
    +	"target": {"name": "M50", "alt":"" },
    +	"ra": { "decimal": 105.6979208, "h":"07", "m":"02", "s":"47.501" },
    +	"dec": { "decimal": -8.3377806, "d": "-08", "m":"20", "s":"16.010" }
    +},{
    +	"target": {"name": "M51", "alt":"" },
    +	"ra": { "decimal": 202.4695750, "h":"13", "m":"29", "s":"52.698" },
    +	"dec": { "decimal": 47.1952583, "d": "+47", "m":"11", "s":"42.930" }
    +},{
    +	"target": {"name": "M52", "alt":"" },
    +	"ra": { "decimal": 351.2000000, "h":"23", "m":"24", "s":"48.000" },
    +	"dec": { "decimal": 61.5933000, "d": "+61", "m":"35", "s":"35.880" }
    +},{
    +	"target": {"name": "M53", "alt":"" },
    +	"ra": { "decimal": 198.2302083, "h":"13", "m":"12", "s":"55.250" },
    +	"dec": { "decimal": 18.1681667, "d": "+18", "m":"10", "s":"05.400" }
    +},{
    +	"target": {"name": "M54", "alt":"" },
    +	"ra": { "decimal": 283.7638750, "h":"18", "m":"55", "s":"03.330" },
    +	"dec": { "decimal": -30.4798611, "d": "-30", "m":"28", "s":"47.500" }
    +},{
    +	"target": {"name": "M55", "alt":"" },
    +	"ra": { "decimal": 294.9987917, "h":"19", "m":"39", "s":"59.710" },
    +	"dec": { "decimal": -30.9647500, "d": "-30", "m":"57", "s":"53.100" }
    +},{
    +	"target": {"name": "M56", "alt":"" },
    +	"ra": { "decimal": 289.1482083, "h":"19", "m":"16", "s":"35.570" },
    +	"dec": { "decimal": 30.1834722, "d": "+30", "m":"11", "s":"00.500" }
    +},{
    +	"target": {"name": "M57", "alt":"" },
    +	"ra": { "decimal": 283.3961625, "h":"18", "m":"53", "s":"35.079" },
    +	"dec": { "decimal": 33.0291750, "d": "+33", "m":"01", "s":"45.030" }
    +},{
    +	"target": {"name": "M58", "alt":"" },
    +	"ra": { "decimal": 189.4316542, "h":"12", "m":"37", "s":"43.597" },
    +	"dec": { "decimal": 11.8180889, "d": "+11", "m":"49", "s":"05.120" }
    +},{
    +	"target": {"name": "M59", "alt":"" },
    +	"ra": { "decimal": 190.5096750, "h":"12", "m":"42", "s":"02.322" },
    +	"dec": { "decimal": 11.6469306, "d": "+11", "m":"38", "s":"48.950" }
    +},{
    +	"target": {"name": "M60", "alt":"" },
    +	"ra": { "decimal": 190.9167000, "h":"12", "m":"43", "s":"40.008" },
    +	"dec": { "decimal": 11.5526111, "d": "+11", "m":"33", "s":"09.400" }
    +},{
    +	"target": {"name": "M61", "alt":"" },
    +	"ra": { "decimal": 185.4789583, "h":"12", "m":"21", "s":"54.950" },
    +	"dec": { "decimal": 4.4735889, "d": "+04", "m":"28", "s":"24.920" }
    +},{
    +	"target": {"name": "M62", "alt":"" },
    +	"ra": { "decimal": 255.3025000, "h":"17", "m":"01", "s":"12.600" },
    +	"dec": { "decimal": -30.1123611, "d": "-30", "m":"06", "s":"44.500" }
    +},{
    +	"target": {"name": "M63", "alt":"" },
    +	"ra": { "decimal": 198.9555375, "h":"13", "m":"15", "s":"49.329" },
    +	"dec": { "decimal": 42.0292889, "d": "+42", "m":"01", "s":"45.440" }
    +},{
    +	"target": {"name": "M64", "alt":"" },
    +	"ra": { "decimal": 194.1820667, "h":"12", "m":"56", "s":"43.696" },
    +	"dec": { "decimal": 21.6826583, "d": "+21", "m":"40", "s":"57.570" }
    +},{
    +	"target": {"name": "M65", "alt":"" },
    +	"ra": { "decimal": 169.7331542, "h":"11", "m":"18", "s":"55.957" },
    +	"dec": { "decimal": 13.0922111, "d": "+13", "m":"05", "s":"31.960" }
    +},{
    +	"target": {"name": "M66", "alt":"" },
    +	"ra": { "decimal": 170.0626083, "h":"11", "m":"20", "s":"15.026" },
    +	"dec": { "decimal": 12.9912889, "d": "+12", "m":"59", "s":"28.640" }
    +},{
    +	"target": {"name": "M67", "alt":"" },
    +	"ra": { "decimal": 132.8250000, "h":"08", "m":"51", "s":"18.000" },
    +	"dec": { "decimal": 11.8000000, "d": "+11", "m":"48", "s":"00.000" }
    +},{
    +	"target": {"name": "M68", "alt":"" },
    +	"ra": { "decimal": 189.8665833, "h":"12", "m":"39", "s":"27.980" },
    +	"dec": { "decimal": -26.7440556, "d": "-26", "m":"44", "s":"38.600" }
    +},{
    +	"target": {"name": "M69", "alt":"" },
    +	"ra": { "decimal": 277.8462500, "h":"18", "m":"31", "s":"23.100" },
    +	"dec": { "decimal": -32.3480833, "d": "-32", "m":"20", "s":"53.100" }
    +},{
    +	"target": {"name": "M70", "alt":"" },
    +	"ra": { "decimal": 280.8031667, "h":"18", "m":"43", "s":"12.760" },
    +	"dec": { "decimal": -32.2921111, "d": "-32", "m":"17", "s":"31.600" }
    +},{
    +	"target": {"name": "M71", "alt":"" },
    +	"ra": { "decimal": 298.4437083, "h":"19", "m":"53", "s":"46.490" },
    +	"dec": { "decimal": 18.7791944, "d": "+18", "m":"46", "s":"45.100" }
    +},{
    +	"target": {"name": "M72", "alt":"" },
    +	"ra": { "decimal": 313.3654167, "h":"20", "m":"53", "s":"27.700" },
    +	"dec": { "decimal": -12.5373056, "d": "-12", "m":"32", "s":"14.300" }
    +},{
    +	"target": {"name": "M73", "alt":"" },
    +	"ra": { "decimal": 314.7500000, "h":"20", "m":"59", "s":"00.000" },
    +	"dec": { "decimal": -12.6330000, "d": "-12", "m":"37", "s":"58.800" }
    +},{
    +	"target": {"name": "M74", "alt":"" },
    +	"ra": { "decimal": 24.1740500, "h":"01", "m":"36", "s":"41.772" },
    +	"dec": { "decimal": 15.7834611, "d": "+15", "m":"47", "s":"00.460" }
    +},{
    +	"target": {"name": "M75", "alt":"" },
    +	"ra": { "decimal": 301.5201708, "h":"20", "m":"06", "s":"04.841" },
    +	"dec": { "decimal": -21.9222611, "d": "-21", "m":"55", "s":"20.140" }
    +},{
    +	"target": {"name": "M76", "alt":"" },
    +	"ra": { "decimal": 25.5820417, "h":"01", "m":"42", "s":"19.690" },
    +	"dec": { "decimal": 51.5754722, "d": "+51", "m":"34", "s":"31.700" }
    +},{
    +	"target": {"name": "M77", "alt":"" },
    +	"ra": { "decimal": 40.6698792, "h":"02", "m":"42", "s":"40.771" },
    +	"dec": { "decimal": -0.0132889, "d": "-00", "m":"00", "s":"47.840" }
    +},{
    +	"target": {"name": "M78", "alt":"" },
    +	"ra": { "decimal": 86.6908292, "h":"05", "m":"46", "s":"45.799" },
    +	"dec": { "decimal": 0.0791694, "d": "+00", "m":"04", "s":"45.010" }
    +},{
    +	"target": {"name": "M79", "alt":"" },
    +	"ra": { "decimal": 81.0441250, "h":"05", "m":"24", "s":"10.590" },
    +	"dec": { "decimal": -24.5242500, "d": "-24", "m":"31", "s":"27.300" }
    +},{
    +	"target": {"name": "M80", "alt":"" },
    +	"ra": { "decimal": 244.2600417, "h":"16", "m":"17", "s":"02.410" },
    +	"dec": { "decimal": -22.9760833, "d": "-22", "m":"58", "s":"33.900" }
    +},{
    +	"target": {"name": "M81", "alt":"" },
    +	"ra": { "decimal": 148.8882208, "h":"09", "m":"55", "s":"33.173" },
    +	"dec": { "decimal": 69.0652944, "d": "+69", "m":"03", "s":"55.060" }
    +},{
    +	"target": {"name": "M82", "alt":"" },
    +	"ra": { "decimal": 148.9684583, "h":"09", "m":"55", "s":"52.430" },
    +	"dec": { "decimal": 69.6797028, "d": "+69", "m":"40", "s":"46.930" }
    +},{
    +	"target": {"name": "M83", "alt":"" },
    +	"ra": { "decimal": 204.2538292, "h":"13", "m":"37", "s":"00.919" },
    +	"dec": { "decimal": -29.8657611, "d": "-29", "m":"51", "s":"56.740" }
    +},{
    +	"target": {"name": "M84", "alt":"" },
    +	"ra": { "decimal": 186.2655958, "h":"12", "m":"25", "s":"03.743" },
    +	"dec": { "decimal": 12.8869833, "d": "+12", "m":"53", "s":"13.140" }
    +},{
    +	"target": {"name": "M85", "alt":"" },
    +	"ra": { "decimal": 186.3502208, "h":"12", "m":"25", "s":"24.053" },
    +	"dec": { "decimal": 18.1910806, "d": "+18", "m":"11", "s":"27.890" }
    +},{
    +	"target": {"name": "M86", "alt":"" },
    +	"ra": { "decimal": 186.5492250, "h":"12", "m":"26", "s":"11.814" },
    +	"dec": { "decimal": 12.9459694, "d": "+12", "m":"56", "s":"45.490" }
    +},{
    +	"target": {"name": "M87", "alt":"" },
    +	"ra": { "decimal": 187.7059292, "h":"12", "m":"30", "s":"49.423" },
    +	"dec": { "decimal": 12.3911222, "d": "+12", "m":"23", "s":"28.040" }
    +},{
    +	"target": {"name": "M88", "alt":"" },
    +	"ra": { "decimal": 187.9967333, "h":"12", "m":"31", "s":"59.216" },
    +	"dec": { "decimal": 14.4204111, "d": "+14", "m":"25", "s":"13.480" }
    +},{
    +	"target": {"name": "M89", "alt":"" },
    +	"ra": { "decimal": 188.9159000, "h":"12", "m":"35", "s":"39.816" },
    +	"dec": { "decimal": 12.5563000, "d": "+12", "m":"33", "s":"22.680" }
    +},{
    +	"target": {"name": "M90", "alt":"" },
    +	"ra": { "decimal": 189.2075667, "h":"12", "m":"36", "s":"49.816" },
    +	"dec": { "decimal": 13.1628694, "d": "+13", "m":"09", "s":"46.330" }
    +},{
    +	"target": {"name": "M91", "alt":"" },
    +	"ra": { "decimal": 188.8601250, "h":"12", "m":"35", "s":"26.430" },
    +	"dec": { "decimal": 14.4963194, "d": "+14", "m":"29", "s":"46.750" }
    +},{
    +	"target": {"name": "M92", "alt":"" },
    +	"ra": { "decimal": 259.2807917, "h":"17", "m":"17", "s":"07.390" },
    +	"dec": { "decimal": 43.1359444, "d": "+43", "m":"08", "s":"09.400" }
    +},{
    +	"target": {"name": "M93", "alt":"" },
    +	"ra": { "decimal": 116.1250000, "h":"07", "m":"44", "s":"30.000" },
    +	"dec": { "decimal": -23.8567000, "d": "-23", "m":"51", "s":"24.120" }
    +},{
    +	"target": {"name": "M94", "alt":"" },
    +	"ra": { "decimal": 192.7214500, "h":"12", "m":"50", "s":"53.148" },
    +	"dec": { "decimal": 41.1201528, "d": "+41", "m":"07", "s":"12.550" }
    +},{
    +	"target": {"name": "M95", "alt":"" },
    +	"ra": { "decimal": 160.9905542, "h":"10", "m":"43", "s":"57.733" },
    +	"dec": { "decimal": 11.7036111, "d": "+11", "m":"42", "s":"13.000" }
    +},{
    +	"target": {"name": "M96", "alt":"" },
    +	"ra": { "decimal": 161.6906000, "h":"10", "m":"46", "s":"45.744" },
    +	"dec": { "decimal": 11.8199389, "d": "+11", "m":"49", "s":"11.780" }
    +},{
    +	"target": {"name": "M97", "alt":"" },
    +	"ra": { "decimal": 168.6987542, "h":"11", "m":"14", "s":"47.701" },
    +	"dec": { "decimal": 55.0190889, "d": "+55", "m":"01", "s":"08.720" }
    +},{
    +	"target": {"name": "M98", "alt":"" },
    +	"ra": { "decimal": 183.4512167, "h":"12", "m":"13", "s":"48.292" },
    +	"dec": { "decimal": 14.9004694, "d": "+14", "m":"54", "s":"01.690" }
    +},{
    +	"target": {"name": "M99", "alt":"" },
    +	"ra": { "decimal": 184.7067708, "h":"12", "m":"18", "s":"49.625" },
    +	"dec": { "decimal": 14.4164889, "d": "+14", "m":"24", "s":"59.360" }
    +},{
    +	"target": {"name": "M100", "alt":"" },
    +	"ra": { "decimal": 185.7287458, "h":"12", "m":"22", "s":"54.899" },
    +	"dec": { "decimal": 15.8223806, "d": "+15", "m":"49", "s":"20.570" }
    +},{
    +	"target": {"name": "M101", "alt":"" },
    +	"ra": { "decimal": 210.8024292, "h":"14", "m":"03", "s":"12.583" },
    +	"dec": { "decimal": 54.3487500, "d": "+54", "m":"20", "s":"55.500" }
    +},{
    +	"target": {"name": "M102", "alt":"" },
    +	"ra": { "decimal": 226.6231708, "h":"15", "m":"06", "s":"29.561" },
    +	"dec": { "decimal": 55.7633083, "d": "+55", "m":"45", "s":"47.910" }
    +},{
    +	"target": {"name": "M103", "alt":"" },
    +	"ra": { "decimal": 23.3458000, "h":"01", "m":"33", "s":"22.992" },
    +	"dec": { "decimal": 60.6500000, "d": "+60", "m":"39", "s":"00.000" }
    +},{
    +	"target": {"name": "M104", "alt":"" },
    +	"ra": { "decimal": 189.9976333, "h":"12", "m":"39", "s":"59.432" },
    +	"dec": { "decimal": -11.6230556, "d": "-11", "m":"37", "s":"23.000" }
    +},{
    +	"target": {"name": "M105", "alt":"" },
    +	"ra": { "decimal": 161.9566667, "h":"10", "m":"47", "s":"49.600" },
    +	"dec": { "decimal": 12.5816306, "d": "+12", "m":"34", "s":"53.870" }
    +},{
    +	"target": {"name": "M106", "alt":"" },
    +	"ra": { "decimal": 184.7400833, "h":"12", "m":"18", "s":"57.620" },
    +	"dec": { "decimal": 47.3037194, "d": "+47", "m":"18", "s":"13.390" }
    +},{
    +	"target": {"name": "M107", "alt":"" },
    +	"ra": { "decimal": 248.1327500, "h":"16", "m":"32", "s":"31.860" },
    +	"dec": { "decimal": -13.0537778, "d": "-13", "m":"03", "s":"13.600" }
    +},{
    +	"target": {"name": "M108", "alt":"" },
    +	"ra": { "decimal": 167.8790417, "h":"11", "m":"11", "s":"30.970" },
    +	"dec": { "decimal": 55.6741111, "d": "+55", "m":"40", "s":"26.800" }
    +},{
    +	"target": {"name": "M109", "alt":"" },
    +	"ra": { "decimal": 179.3999333, "h":"11", "m":"57", "s":"35.984" },
    +	"dec": { "decimal": 53.3745194, "d": "+53", "m":"22", "s":"28.270" }
    +},{
    +	"target": {"name": "M110", "alt":"" },
    +	"ra": { "decimal": 10.0919792, "h":"00", "m":"40", "s":"22.075" },
    +	"dec": { "decimal": 41.6853000, "d": "+41", "m":"41", "s":"07.080" }
    +}]
    diff --git a/html/allsky/virtualsky/planets.json b/html/allsky/virtualsky/planets.json
    new file mode 100755
    index 000000000..1e3c1bc71
    --- /dev/null
    +++ b/html/allsky/virtualsky/planets.json
    @@ -0,0 +1,11 @@
    +{
    +	"planets" : [
    +		["Me","rgb(170,150,170)",[2456474.5,113.62579,18.47100,3.23,2456475.0,113.42810,18.37483,3.37,2456475.5,113.21445,18.28334,3.51,2456476.0,112.98555,18.19668,3.67,2456476.5,112.74219,18.11498,3.82,2456477.0,112.48519,18.03835,3.98,2456477.5,112.21546,17.96692,4.14,2456478.0,111.93396,17.90078,4.31,2456478.5,111.64170,17.84003,4.47,2456479.0,111.33977,17.78476,4.64,2456479.5,111.02929,17.73503,4.80,2456480.0,110.71144,17.69090,4.96,2456480.5,110.38744,17.65243,5.11,2456481.0,110.05858,17.61965,5.25,2456481.5,109.72614,17.59258,5.37,2456482.0,109.39146,17.57125,5.46,2456482.5,109.05591,17.55563,5.52,2456483.0,108.72085,17.54573,5.54,2456483.5,108.38768,17.54151,5.52,2456484.0,108.05779,17.54293,5.47,2456484.5,107.73256,17.54994,5.37,2456485.0,107.41338,17.56248,5.25,2456485.5,107.10161,17.58045,5.11,2456486.0,106.79859,17.60377,4.95,2456486.5,106.50564,17.63234,4.77,2456487.0,106.22404,17.66603,4.60,2456487.5,105.95502,17.70471,4.41,2456488.0,105.69980,17.74824,4.23,2456488.5,105.45951,17.79647,4.04,2456489.0,105.23525,17.84924,3.86,2456489.5,105.02808,17.90636,3.68,2456490.0,104.83898,17.96764,3.50,2456490.5,104.66889,18.03290,3.32,2456491.0,104.51866,18.10192,3.15,2456491.5,104.38913,18.17448,2.98,2456492.0,104.28104,18.25036,2.81,2456492.5,104.19509,18.32933,2.66,2456493.0,104.13191,18.41114,2.50,2456493.5,104.09208,18.49553,2.35,2456494.0,104.07613,18.58224,2.20,2456494.5,104.08453,18.67102,2.06,2456495.0,104.11770,18.76157,1.92,2456495.5,104.17601,18.85361,1.79,2456496.0,104.25977,18.94686,1.66,2456496.5,104.36927,19.04101,1.53,2456497.0,104.50474,19.13575,1.41,2456497.5,104.66637,19.23078,1.29,2456498.0,104.85433,19.32577,1.18,2456498.5,105.06872,19.42040,1.07,2456499.0,105.30964,19.51434,0.96,2456499.5,105.57713,19.60724,0.86,2456500.0,105.87122,19.69877,0.76,2456500.5,106.19189,19.78857,0.66,2456501.0,106.53910,19.87629,0.57,2456501.5,106.91279,19.96156,0.48,2456502.0,107.31286,20.04402,0.39,2456502.5,107.73918,20.12330,0.31,2456503.0,108.19161,20.19902,0.23,2456503.5,108.66996,20.27080,0.15,2456504.0,109.17404,20.33826,0.07,2456504.5,109.70360,20.40100,0.07,2456505.0,110.25838,20.45865,-0.07,2456505.5,110.83809,20.51081,-0.14,2456506.0,111.44240,20.55708,-0.21,2456506.5,112.07095,20.59709,-0.27,2456507.0,112.72336,20.63043,-0.33,2456507.5,113.39919,20.65673,-0.39,2456508.0,114.09797,20.67561,-0.45,2456508.5,114.81922,20.68668,-0.50,2456509.0,115.56237,20.68960,-0.56,2456509.5,116.32686,20.68399,-0.61,2456510.0,117.11205,20.66953,-0.66,2456510.5,117.91728,20.64589,-0.70,2456511.0,118.74185,20.61275,-0.75,2456511.5,119.58500,20.56982,-0.79,2456512.0,120.44595,20.51685,-0.83,2456512.5,121.32388,20.45357,-0.88,2456513.0,122.21791,20.37978,-0.91,2456513.5,123.12714,20.29528,-0.95,2456514.0,124.05065,20.19991,-0.99,2456514.5,124.98748,20.09355,-1.03,2456515.0,125.93665,19.97608,-1.06,2456515.5,126.89715,19.84746,-1.09,2456516.0,127.86797,19.70765,-1.13,2456516.5,128.84809,19.55666,-1.16,2456517.0,129.83648,19.39453,-1.19,2456517.5,130.83211,19.22133,-1.23,2456518.0,131.83397,19.03716,-1.26,2456518.5,132.84106,18.84218,-1.29,2456519.0,133.85239,18.63655,-1.33,2456519.5,134.86701,18.42046,-1.36,2456520.0,135.88398,18.19416,-1.39,2456520.5,136.90242,17.95787,-1.43,2456521.0,137.92147,17.71189,-1.46,2456521.5,138.94031,17.45649,-1.50,2456522.0,139.95817,17.19199,-1.53,2456522.5,140.97432,16.91870,-1.57,2456523.0,141.98809,16.63697,-1.60,2456523.5,142.99884,16.34713,-1.64,2456524.0,144.00599,16.04954,-1.68,2456524.5,145.00901,15.74454,-1.72,2456525.0,146.00741,15.43249,-1.75,2456525.5,147.00077,15.11374,-1.79,2456526.0,147.98868,14.78865,-1.83,2456526.5,148.97080,14.45757,-1.87,2456527.0,149.94682,14.12083,-1.90,2456527.5,150.91648,13.77879,-1.93,2456528.0,151.87956,13.43176,-1.96,2456528.5,152.83586,13.08008,-1.97,2456529.0,153.78523,12.72405,-1.98,2456529.5,154.72753,12.36398,-1.96,2456530.0,155.66267,12.00017,-1.93,2456530.5,156.59057,11.63290,-1.88,2456531.0,157.51118,11.26245,-1.83,2456531.5,158.42447,10.88910,-1.77,2456532.0,159.33045,10.51308,-1.71,2456532.5,160.22913,10.13465,-1.64,2456533.0,161.12052,9.75405,-1.58,2456533.5,162.00467,9.37149,-1.52,2456534.0,162.88164,8.98719,-1.46,2456534.5,163.75149,8.60137,-1.40,2456535.0,164.61430,8.21421,-1.35,2456535.5,165.47016,7.82591,-1.29,2456536.0,166.31915,7.43664,-1.24,2456536.5,167.16138,7.04657,-1.19,2456537.0,167.99694,6.65588,-1.14,2456537.5,168.82596,6.26471,-1.09,2456538.0,169.64854,5.87321,-1.05,2456538.5,170.46479,5.48153,-1.00,2456539.0,171.27485,5.08980,-0.96,2456539.5,172.07881,4.69815,-0.92,2456540.0,172.87681,4.30671,-0.88,2456540.5,173.66897,3.91559,-0.84,2456541.0,174.45541,3.52491,-0.81,2456541.5,175.23625,3.13477,-0.77,2456542.0,176.01161,2.74528,-0.74,2456542.5,176.78161,2.35654,-0.71,2456543.0,177.54636,1.96863,-0.68,2456543.5,178.30599,1.58166,-0.65,2456544.0,179.06061,1.19571,-0.62,2456544.5,179.81033,0.81086,-0.59,2456545.0,180.55527,0.42720,-0.56,2456545.5,181.29551,0.04480,-0.54,2456546.0,182.03118,-0.33626,-0.51,2456546.5,182.76238,-0.71592,-0.49,2456547.0,183.48920,-1.09409,-0.46,2456547.5,184.21173,-1.47071,-0.44,2456548.0,184.93007,-1.84571,-0.42,2456548.5,185.64431,-2.21903,-0.40,2456549.0,186.35454,-2.59061,-0.38,2456549.5,187.06082,-2.96039,-0.36,2456550.0,187.76325,-3.32830,-0.34,2456550.5,188.46190,-3.69428,-0.33,2456551.0,189.15683,-4.05828,-0.31,2456551.5,189.84812,-4.42024,-0.30,2456552.0,190.53583,-4.78011,-0.28,2456552.5,191.22002,-5.13784,-0.27,2456553.0,191.90075,-5.49335,-0.25,2456553.5,192.57806,-5.84662,-0.24,2456554.0,193.25201,-6.19757,-0.23,2456554.5,193.92263,-6.54616,-0.22,2456555.0,194.58998,-6.89234,-0.20,2456555.5,195.25407,-7.23605,-0.19,2456556.0,195.91495,-7.57724,-0.18,2456556.5,196.57263,-7.91586,-0.17,2456557.0,197.22713,-8.25186,-0.16,2456557.5,197.87847,-8.58518,-0.16,2456558.0,198.52664,-8.91576,-0.15,2456558.5,199.17166,-9.24356,-0.14,2456559.0,199.81350,-9.56851,-0.13,2456559.5,200.45217,-9.89057,-0.13,2456560.0,201.08763,-10.20967,-0.12,2456560.5,201.71986,-10.52575,-0.12,2456561.0,202.34883,-10.83876,-0.11,2456561.5,202.97449,-11.14863,-0.11,2456562.0,203.59679,-11.45531,-0.10,2456562.5,204.21567,-11.75872,-0.10,2456563.0,204.83107,-12.05880,-0.09,2456563.5,205.44290,-12.35549,-0.09,2456564.0,206.05108,-12.64871,-0.09,2456564.5,206.65551,-12.93840,-0.09,2456565.0,207.25609,-13.22447,-0.08,2456565.5,207.85269,-13.50686,-0.08,2456566.0,208.44519,-13.78548,-0.08,2456566.5,209.03344,-14.06026,-0.08,2456567.0,209.61728,-14.33110,-0.08,2456567.5,210.19656,-14.59793,-0.08,2456568.0,210.77108,-14.86064,-0.08,2456568.5,211.34064,-15.11916,-0.08,2456569.0,211.90504,-15.37337,-0.07,2456569.5,212.46404,-15.62318,-0.07,2456570.0,213.01740,-15.86848,-0.07,2456570.5,213.56485,-16.10916,-0.07,2456571.0,214.10609,-16.34510,-0.07,2456571.5,214.64084,-16.57619,-0.07,2456572.0,215.16875,-16.80229,-0.07,2456572.5,215.68948,-17.02328,-0.07,2456573.0,216.20266,-17.23901,-0.07,2456573.5,216.70787,-17.44934,-0.07,2456574.0,217.20471,-17.65411,-0.07,2456574.5,217.69272,-17.85317,-0.07,2456575.0,218.17141,-18.04635,-0.07,2456575.5,218.64027,-18.23346,-0.06,2456576.0,219.09877,-18.41432,-0.06,2456576.5,219.54633,-18.58873,-0.06,2456577.0,219.98234,-18.75649,-0.05,2456577.5,220.40616,-18.91737,-0.04,2456578.0,220.81711,-19.07114,-0.04,2456578.5,221.21449,-19.21756,-0.03,2456579.0,221.59754,-19.35636,-0.02,2456579.5,221.96547,-19.48728,-0.01,2456580.0,222.31746,-19.61003,-0.01,2456580.5,222.65264,-19.72430,0.02,2456581.0,222.97012,-19.82978,0.04,2456581.5,223.26894,-19.92612,0.06,2456582.0,223.54814,-20.01296,0.08,2456582.5,223.80669,-20.08995,0.10,2456583.0,224.04356,-20.15667,0.13,2456583.5,224.25766,-20.21271,0.16,2456584.0,224.44790,-20.25765,0.20,2456584.5,224.61314,-20.29103,0.24,2456585.0,224.75227,-20.31237,0.29,2456585.5,224.86412,-20.32118,0.34,2456586.0,224.94757,-20.31697,0.40,2456586.5,225.00149,-20.29920,0.46,2456587.0,225.02479,-20.26736,0.54,2456587.5,225.01642,-20.22090,0.62,2456588.0,224.97539,-20.15928,0.71,2456588.5,224.90081,-20.08199,0.81,2456589.0,224.79188,-19.98850,0.92,2456589.5,224.64794,-19.87833,1.04,2456590.0,224.46848,-19.75102,1.18,2456590.5,224.25319,-19.60617,1.33,2456591.0,224.00197,-19.44344,1.50,2456591.5,223.71498,-19.26260,1.69,2456592.0,223.39266,-19.06348,1.89,2456592.5,223.03575,-18.84607,2.11,2456593.0,222.64534,-18.61050,2.36,2456593.5,222.22287,-18.35704,2.63,2456594.0,221.77019,-18.08618,2.92,2456594.5,221.28951,-17.79861,3.24,2456595.0,220.78346,-17.49523,3.59,2456595.5,220.25505,-17.17719,3.97,2456596.0,219.70765,-16.84586,4.38,2456596.5,219.14500,-16.50288,4.82,2456597.0,218.57113,-16.15009,4.82,2456597.5,217.99033,-15.78957,4.82,2456598.0,217.40708,-15.42358,4.82,2456598.5,216.82599,-15.05455,4.82,2456599.0,216.25172,-14.68502,4.82,2456599.5,215.68889,-14.31761,4.82,2456600.0,215.14204,-13.95496,4.98,2456600.5,214.61552,-13.59970,4.48,2456601.0,214.11344,-13.25438,4.01,2456601.5,213.63960,-12.92141,3.57,2456602.0,213.19743,-12.60306,3.16,2456602.5,212.78998,-12.30139,2.78,2456603.0,212.41988,-12.01823,2.42,2456603.5,212.08932,-11.75514,2.10,2456604.0,211.80002,-11.51345,1.80,2456604.5,211.55331,-11.29421,1.52,2456605.0,211.35007,-11.09818,1.27,2456605.5,211.19080,-10.92590,1.04,2456606.0,211.07562,-10.77763,0.84,2456606.5,211.00433,-10.65344,0.65,2456607.0,210.97645,-10.55317,0.48,2456607.5,210.99120,-10.47649,0.33,2456608.0,211.04762,-10.42290,0.20,2456608.5,211.14456,-10.39178,0.08,2456609.0,211.28070,-10.38240,-0.03,2456609.5,211.45464,-10.39392,-0.12,2456610.0,211.66485,-10.42544,-0.21,2456610.5,211.90979,-10.47602,-0.28,2456611.0,212.18786,-10.54466,-0.34,2456611.5,212.49744,-10.63037,-0.40,2456612.0,212.83693,-10.73211,-0.45,2456612.5,213.20475,-10.84889,-0.49,2456613.0,213.59934,-10.97969,-0.52,2456613.5,214.01918,-11.12353,-0.55,2456614.0,214.46282,-11.27946,-0.58,2456614.5,214.92885,-11.44653,-0.60,2456615.0,215.41592,-11.62384,-0.62,2456615.5,215.92275,-11.81055,-0.63,2456616.0,216.44812,-12.00581,-0.64,2456616.5,216.99088,-12.20884,-0.65,2456617.0,217.54993,-12.41889,-0.66,2456617.5,218.12426,-12.63524,-0.66,2456618.0,218.71290,-12.85723,-0.67,2456618.5,219.31495,-13.08420,-0.67,2456619.0,219.92957,-13.31557,-0.67,2456619.5,220.55597,-13.55076,-0.67,2456620.0,221.19343,-13.78924,-0.67,2456620.5,221.84126,-14.03051,-0.67,2456621.0,222.49882,-14.27409,-0.67,2456621.5,223.16554,-14.51955,-0.67,2456622.0,223.84087,-14.76647,-0.66,2456622.5,224.52431,-15.01446,-0.66,2456623.0,225.21538,-15.26315,-0.66,2456623.5,225.91366,-15.51220,-0.66,2456624.0,226.61876,-15.76130,-0.65,2456624.5,227.33030,-16.01014,-0.65,2456625.0,228.04795,-16.25844,-0.65,2456625.5,228.77139,-16.50593,-0.65,2456626.0,229.50033,-16.75237,-0.65,2456626.5,230.23452,-16.99753,-0.64,2456627.0,230.97370,-17.24118,-0.64,2456627.5,231.71765,-17.48312,-0.64,2456628.0,232.46616,-17.72316,-0.64,2456628.5,233.21904,-17.96111,-0.64,2456629.0,233.97611,-18.19681,-0.64,2456629.5,234.73722,-18.43009,-0.64,2456630.0,235.50220,-18.66081,-0.64,2456630.5,236.27092,-18.88880,-0.65,2456631.0,237.04325,-19.11395,-0.65,2456631.5,237.81907,-19.33612,-0.65,2456632.0,238.59827,-19.55519,-0.65,2456632.5,239.38076,-19.77103,-0.66,2456633.0,240.16644,-19.98355,-0.66,2456633.5,240.95522,-20.19263,-0.67,2456634.0,241.74703,-20.39818,-0.67,2456634.5,242.54179,-20.60009,-0.68,2456635.0,243.33944,-20.79828,-0.68,2456635.5,244.13992,-20.99265,-0.69,2456636.0,244.94317,-21.18313,-0.70,2456636.5,245.74914,-21.36964,-0.70,2456637.0,246.55777,-21.55209,-0.71,2456637.5,247.36903,-21.73041,-0.72,2456638.0,248.18286,-21.90453,-0.73,2456638.5,248.99923,-22.07438,-0.74,2456639.0,249.81810,-22.23990,-0.75,2456639.5,250.63943,-22.40101,-0.76,2456640.0,251.46317,-22.55765,-0.77,2456640.5,252.28931,-22.70976,-0.78,2456641.0,253.11780,-22.85728,-0.79,2456641.5,253.94860,-23.00016,-0.81,2456642.0,254.78170,-23.13833,-0.82,2456642.5,255.61704,-23.27174,-0.84,2456643.0,256.45461,-23.40033,-0.85,2456643.5,257.29436,-23.52405,-0.87,2456644.0,258.13626,-23.64285,-0.88,2456644.5,258.98029,-23.75668,-0.90,2456645.0,259.82641,-23.86548,-0.91,2456645.5,260.67458,-23.96920,-0.93,2456646.0,261.52478,-24.06780,-0.95,2456646.5,262.37696,-24.16122,-0.97,2456647.0,263.23109,-24.24942,-0.99,2456647.5,264.08713,-24.33236,-1.01,2456648.0,264.94506,-24.40998,-1.03,2456648.5,265.80482,-24.48223,-1.05,2456649.0,266.66639,-24.54908,-1.07,2456649.5,267.52972,-24.61048,-1.09,2456650.0,268.39478,-24.66639,-1.11,2456650.5,269.26151,-24.71675,-1.14,2456651.0,270.12988,-24.76153,-1.16,2456651.5,270.99984,-24.80069,-1.18,2456652.0,271.87135,-24.83419,-1.20,2456652.5,272.74435,-24.86198,-1.23,2456653.0,273.61881,-24.88402,-1.25,2456653.5,274.49467,-24.90027,-1.27,2456654.0,275.37187,-24.91070,-1.29,2456654.5,276.25037,-24.91526,-1.30,2456655.0,277.13011,-24.91392,-1.31,2456655.5,278.01103,-24.90664,-1.32,2456656.0,278.89307,-24.89338,-1.32,2456656.5,279.77617,-24.87411,-1.32,2456657.0,280.66025,-24.84880,-1.32,2456657.5,281.54526,-24.81740,-1.31,2456658.0,282.43113,-24.77989,-1.30,2456658.5,283.31777,-24.73623,-1.29,2456659.0,284.20512,-24.68640,-1.27,2456659.5,285.09311,-24.63036,-1.26,2456660.0,285.98164,-24.56808,-1.25,2456660.5,286.87064,-24.49953,-1.23,2456661.0,287.76002,-24.42470,-1.22,2456661.5,288.64970,-24.34354,-1.20,2456662.0,289.53959,-24.25603,-1.19,2456662.5,290.42959,-24.16216,-1.18,2456663.0,291.31961,-24.06189,-1.16,2456663.5,292.20955,-23.95522,-1.15,2456664.0,293.09931,-23.84211,-1.13,2456664.5,293.98879,-23.72255,-1.12,2456665.0,294.87786,-23.59654,-1.11,2456665.5,295.76642,-23.46405,-1.10,2456666.0,296.65434,-23.32507,-1.09,2456666.5,297.54150,-23.17961,-1.08,2456667.0,298.42776,-23.02765,-1.06,2456667.5,299.31299,-22.86920,-1.05,2456668.0,300.19703,-22.70425,-1.04,2456668.5,301.07974,-22.53282,-1.03,2456669.0,301.96094,-22.35491,-1.03,2456669.5,302.84048,-22.17054,-1.02,2456670.0,303.71816,-21.97973,-1.01,2456670.5,304.59379,-21.78250,-1.00,2456671.0,305.46717,-21.57888,-0.99,2456671.5,306.33809,-21.36891,-0.99,2456672.0,307.20631,-21.15263,-0.98,2456672.5,308.07160,-20.93010,-0.97,2456673.0,308.93369,-20.70137,-0.97,2456673.5,309.79230,-20.46650,-0.96,2456674.0,310.64716,-20.22558,-0.96,2456674.5,311.49793,-19.97869,-0.95,2456675.0,312.34429,-19.72592,-0.95,2456675.5,313.18589,-19.46739,-0.95,2456676.0,314.02233,-19.20322,-0.94,2456676.5,314.85323,-18.93355,-0.94,2456677.0,315.67814,-18.65851,-0.94,2456677.5,316.49660,-18.37829,-0.93,2456678.0,317.30812,-18.09307,-0.93,2456678.5,318.11217,-17.80305,-0.93,2456679.0,318.90819,-17.50845,-0.93,2456679.5,319.69558,-17.20952,-0.92,2456680.0,320.47369,-16.90653,-0.92,2456680.5,321.24185,-16.59977,-0.92,2456681.0,321.99933,-16.28956,-0.92,2456681.5,322.74537,-15.97624,-0.91,2456682.0,323.47913,-15.66019,-0.91,2456682.5,324.19977,-15.34182,-0.90,2456683.0,324.90635,-15.02155,-0.90,2456683.5,325.59792,-14.69988,-0.89,2456684.0,326.27345,-14.37729,-0.88,2456684.5,326.93188,-14.05432,-0.87,2456685.0,327.57207,-13.73156,-0.86,2456685.5,328.19287,-13.40962,-0.84,2456686.0,328.79304,-13.08914,-0.82,2456686.5,329.37133,-12.77081,-0.80,2456687.0,329.92643,-12.45535,-0.78,2456687.5,330.45698,-12.14352,-0.75,2456688.0,330.96163,-11.83610,-0.72,2456688.5,331.43896,-11.53393,-0.68,2456689.0,331.88756,-11.23784,-0.64,2456689.5,332.30602,-10.94872,-0.59,2456690.0,332.69291,-10.66746,-0.54,2456690.5,333.04684,-10.39499,-0.48,2456691.0,333.36645,-10.13221,-0.41,2456691.5,333.65040,-9.88007,-0.33,2456692.0,333.89745,-9.63951,-0.24,2456692.5,334.10642,-9.41144,-0.15,2456693.0,334.27622,-9.19678,-0.04,2456693.5,334.40590,-8.99641,0.08,2456694.0,334.49463,-8.81119,0.21,2456694.5,334.54177,-8.64192,0.35,2456695.0,334.54683,-8.48935,0.50,2456695.5,334.50953,-8.35418,0.67,2456696.0,334.42983,-8.23702,0.86,2456696.5,334.30791,-8.13840,1.06,2456697.0,334.14423,-8.05875,1.27,2456697.5,333.93949,-7.99840,1.50,2456698.0,333.69472,-7.95756,1.74,2456698.5,333.41120,-7.93632,2.01,2456699.0,333.09054,-7.93463,2.28,2456699.5,332.73462,-7.95231,2.58,2456700.0,332.34563,-7.98904,2.88,2456700.5,331.92602,-8.04437,3.20,2456701.0,331.47852,-8.11770,3.53,2456701.5,331.00608,-8.20829,3.87,2456702.0,330.51185,-8.31529,4.20,2456702.5,329.99917,-8.43772,4.53,2456703.0,329.47150,-8.57451,4.82,2456703.5,328.93242,-8.72449,5.06,2456704.0,328.38553,-8.88640,5.21,2456704.5,327.83446,-9.05896,5.25,2456705.0,327.28278,-9.24082,5.17,2456705.5,326.73400,-9.43063,4.99,2456706.0,326.19150,-9.62702,4.76,2456706.5,325.65852,-9.82865,4.49,2456707.0,325.13809,-10.03421,4.21,2456707.5,324.63306,-10.24241,3.93,2456708.0,324.14603,-10.45206,3.66,2456708.5,323.67934,-10.66200,3.40,2456709.0,323.23511,-10.87115,3.15,2456709.5,322.81518,-11.07853,2.91,2456710.0,322.42112,-11.28323,2.69,2456710.5,322.05428,-11.48440,2.49,2456711.0,321.71573,-11.68132,2.30,2456711.5,321.40633,-11.87330,2.12,2456712.0,321.12673,-12.05977,1.95,2456712.5,320.87734,-12.24022,1.80,2456713.0,320.65843,-12.41420,1.65,2456713.5,320.47007,-12.58133,1.52,2456714.0,320.31219,-12.74129,1.40,2456714.5,320.18459,-12.89383,1.29,2456715.0,320.08695,-13.03872,1.18,2456715.5,320.01884,-13.17580,1.09,2456716.0,319.97977,-13.30493,1.00,2456716.5,319.96914,-13.42601,0.92,2456717.0,319.98634,-13.53898,0.85,2456717.5,320.03067,-13.64379,0.78,2456718.0,320.10142,-13.74042,0.72,2456718.5,320.19784,-13.82886,0.66,2456719.0,320.31916,-13.90914,0.61,2456719.5,320.46462,-13.98128,0.56,2456720.0,320.63342,-14.04532,0.51,2456720.5,320.82479,-14.10131,0.47,2456721.0,321.03795,-14.14931,0.44,2456721.5,321.27212,-14.18938,0.40,2456722.0,321.52656,-14.22159,0.37,2456722.5,321.80051,-14.24601,0.34,2456723.0,322.09325,-14.26272,0.32,2456723.5,322.40407,-14.27180,0.29,2456724.0,322.73228,-14.27331,0.27,2456724.5,323.07721,-14.26735,0.25,2456725.0,323.43821,-14.25398,0.23,2456725.5,323.81465,-14.23330,0.22,2456726.0,324.20592,-14.20538,0.20,2456726.5,324.61145,-14.17029,0.19,2456727.0,325.03067,-14.12811,0.17,2456727.5,325.46305,-14.07893,0.16,2456728.0,325.90805,-14.02281,0.15,2456728.5,326.36519,-13.95982,0.14,2456729.0,326.83400,-13.89005,0.13,2456729.5,327.31401,-13.81355,0.12,2456730.0,327.80478,-13.73041,0.11,2456730.5,328.30592,-13.64068,0.10,2456731.0,328.81701,-13.54443,0.10,2456731.5,329.33767,-13.44173,0.09,2456732.0,329.86755,-13.33264,0.08,2456732.5,330.40631,-13.21722,0.07,2456733.0,330.95361,-13.09553,0.07,2456733.5,331.50914,-12.96762,0.06,2456734.0,332.07262,-12.83357,0.06,2456734.5,332.64375,-12.69341,0.05,2456735.0,333.22227,-12.54721,0.04,2456735.5,333.80793,-12.39502,0.04,2456736.0,334.40049,-12.23690,0.03,2456736.5,334.99972,-12.07289,0.03,2456737.0,335.60542,-11.90304,0.02,2456737.5,336.21737,-11.72740,0.01,2456738.0,336.83539,-11.54603,0.01,2456738.5,337.45929,-11.35897,0.01,2456739.0,338.08892,-11.16626,-0.01,2456739.5,338.72411,-10.96795,-0.01,2456740.0,339.36470,-10.76410,-0.02,2456740.5,340.01057,-10.55473,-0.03,2456741.0,340.66158,-10.33990,-0.03,2456741.5,341.31762,-10.11965,-0.04,2456742.0,341.97856,-9.89402,-0.05,2456742.5,342.64431,-9.66305,-0.06,2456743.0,343.31477,-9.42679,-0.07,2456743.5,343.98986,-9.18527,-0.08,2456744.0,344.66950,-8.93854,-0.09,2456744.5,345.35362,-8.68663,-0.10,2456745.0,346.04216,-8.42959,-0.11,2456745.5,346.73505,-8.16745,-0.12,2456746.0,347.43227,-7.90025,-0.13,2456746.5,348.13375,-7.62804,-0.14,2456747.0,348.83949,-7.35084,-0.15,2456747.5,349.54944,-7.06869,-0.17,2456748.0,350.26359,-6.78165,-0.18,2456748.5,350.98192,-6.48973,-0.19,2456749.0,351.70444,-6.19298,-0.21,2456749.5,352.43114,-5.89145,-0.22,2456750.0,353.16202,-5.58516,-0.24,2456750.5,353.89710,-5.27415,-0.25,2456751.0,354.63640,-4.95847,-0.27,2456751.5,355.37995,-4.63816,-0.29,2456752.0,356.12776,-4.31325,-0.31,2456752.5,356.87988,-3.98379,-0.33,2456753.0,357.63635,-3.64982,-0.35,2456753.5,358.39722,-3.31139,-0.37,2456754.0,359.16253,-2.96853,-0.39,2456754.5,359.93234,-2.62130,-0.41,2456755.0,0.70672,-2.26973,-0.43,2456755.5,1.48574,-1.91389,-0.46,2456756.0,2.26945,-1.55382,-0.48,2456756.5,3.05794,-1.18958,-0.51,2456757.0,3.85130,-0.82122,-0.54,2456757.5,4.64959,-0.44879,-0.56,2456758.0,5.45292,-0.07237,-0.59,2456758.5,6.26137,0.30799,-0.62,2456759.0,7.07504,0.69222,-0.65,2456759.5,7.89403,1.08024,-0.69,2456760.0,8.71844,1.47198,-0.72,2456760.5,9.54838,1.86737,-0.76,2456761.0,10.38394,2.26631,-0.79,2456761.5,11.22524,2.66871,-0.83,2456762.0,12.07238,3.07448,-0.87,2456762.5,12.92547,3.48352,-0.91,2456763.0,13.78462,3.89571,-0.95,2456763.5,14.64995,4.31094,-1.00,2456764.0,15.52154,4.72909,-1.04,2456764.5,16.39951,5.15003,-1.09,2456765.0,17.28395,5.57360,-1.14,2456765.5,18.17496,5.99966,-1.19,2456766.0,19.07262,6.42806,-1.25,2456766.5,19.97702,6.85860,-1.30,2456767.0,20.88823,7.29112,-1.36,2456767.5,21.80631,7.72542,-1.42,2456768.0,22.73131,8.16128,-1.49,2456768.5,23.66327,8.59849,-1.55,2456769.0,24.60222,9.03681,-1.62,2456769.5,25.54816,9.47599,-1.70,2456770.0,26.50110,9.91577,-1.77,2456770.5,27.46100,10.35587,-1.85,2456771.0,28.42781,10.79599,-1.93,2456771.5,29.40146,11.23583,-2.02,2456772.0,30.38186,11.67505,-2.11,2456772.5,31.36889,12.11333,-2.20,2456773.0,32.36239,12.55030,-2.29,2456773.5,33.36221,12.98557,-2.29,2456774.0,34.36816,13.41890,-2.29,2456774.5,35.37988,13.84975,-2.32,2456775.0,36.39717,14.27774,-2.25,2456775.5,37.41974,14.70247,-2.18,2456776.0,38.44723,15.12352,-2.12,2456776.5,39.47926,15.54046,-2.05,2456777.0,40.51541,15.95287,-1.99,2456777.5,41.55526,16.36031,-1.92,2456778.0,42.59830,16.76234,-1.86,2456778.5,43.64405,17.15854,-1.80,2456779.0,44.69196,17.54847,-1.74,2456779.5,45.74146,17.93173,-1.69,2456780.0,46.79198,18.30790,-1.63,2456780.5,47.84290,18.67660,-1.58,2456781.0,48.89361,19.03745,-1.53,2456781.5,49.94345,19.39009,-1.48,2456782.0,50.99179,19.73418,-1.43,2456782.5,52.03796,20.06941,-1.38,2456783.0,53.08130,20.39548,-1.34,2456783.5,54.12115,20.71213,-1.29,2456784.0,55.15686,21.01911,-1.25,2456784.5,56.18777,21.31622,-1.20,2456785.0,57.21324,21.60328,-1.16,2456785.5,58.23263,21.88011,-1.12,2456786.0,59.24533,22.14660,-1.08,2456786.5,60.25074,22.40263,-1.04,2456787.0,61.24827,22.64814,-0.99,2456787.5,62.23735,22.88307,-0.95,2456788.0,63.21744,23.10739,-0.91,2456788.5,64.18802,23.32110,-0.87,2456789.0,65.14857,23.52423,-0.83,2456789.5,66.09861,23.71680,-0.79,2456790.0,67.03768,23.89889,-0.75,2456790.5,67.96533,24.07056,-0.71,2456791.0,68.88114,24.23191,-0.67,2456791.5,69.78470,24.38304,-0.63,2456792.0,70.67563,24.52409,-0.59,2456792.5,71.55356,24.65517,-0.55,2456793.0,72.41813,24.77644,-0.51,2456793.5,73.26900,24.88805,-0.47,2456794.0,74.10585,24.99016,-0.43,2456794.5,74.92837,25.08294,-0.39,2456795.0,75.73626,25.16656,-0.34,2456795.5,76.52922,25.24121,-0.30,2456796.0,77.30699,25.30706,-0.26,2456796.5,78.06929,25.36430,-0.21,2456797.0,78.81587,25.41313,-0.17,2456797.5,79.54646,25.45374,-0.13,2456798.0,80.26083,25.48632,-0.08,2456798.5,80.95873,25.51106,-0.03,2456799.0,81.63992,25.52816,0.01,2456799.5,82.30418,25.53782,0.06,2456800.0,82.95128,25.54023,0.11,2456800.5,83.58099,25.53558,0.15,2456801.0,84.19310,25.52407,0.20,2456801.5,84.78738,25.50590,0.25,2456802.0,85.36362,25.48125,0.30,2456802.5,85.92161,25.45032,0.35,2456803.0,86.46113,25.41329,0.41,2456803.5,86.98198,25.37035,0.46,2456804.0,87.48395,25.32169,0.51,2456804.5,87.96683,25.26750,0.57,2456805.0,88.43042,25.20795,0.63,2456805.5,88.87453,25.14322,0.69,2456806.0,89.29895,25.07350,0.75,2456806.5,89.70351,24.99896,0.81,2456807.0,90.08802,24.91978,0.87,2456807.5,90.45229,24.83612,0.94,2456808.0,90.79617,24.74818,1.00,2456808.5,91.11949,24.65610,1.07,2456809.0,91.42210,24.56007,1.14,2456809.5,91.70386,24.46024,1.22,2456810.0,91.96465,24.35680,1.29,2456810.5,92.20436,24.24990,1.37,2456811.0,92.42288,24.13971,1.45,2456811.5,92.62015,24.02639,1.53,2456812.0,92.79611,23.91012,1.62,2456812.5,92.95073,23.79105,1.71,2456813.0,93.08400,23.66936,1.80,2456813.5,93.19593,23.54520,1.90,2456814.0,93.28659,23.41875,2.00,2456814.5,93.35605,23.29018,2.10,2456815.0,93.40442,23.15966,2.20,2456815.5,93.43186,23.02735,2.31,2456816.0,93.43856,22.89344,2.43,2456816.5,93.42477,22.75811,2.55,2456817.0,93.39075,22.62154,2.67,2456817.5,93.33684,22.48391,2.80,2456818.0,93.26342,22.34542,2.93,2456818.5,93.17091,22.20626,3.07,2456819.0,93.05979,22.06663,3.21,2456819.5,92.93061,21.92674,3.35,2456820.0,92.78396,21.78679,3.50,2456820.5,92.62048,21.64701,3.66,2456821.0,92.44088,21.50762,3.82,2456821.5,92.24593,21.36885,3.98,2456822.0,92.03645,21.23092,4.15,2456822.5,91.81331,21.09408,4.33,2456823.0,91.57744,20.95858,4.50,2456823.5,91.32982,20.82466,4.68,2456824.0,91.07148,20.69258,4.86,2456824.5,90.80351,20.56259,5.04,2456825.0,90.52700,20.43496,5.21,2456825.5,90.24313,20.30995,5.38,2456826.0,89.95307,20.18783,5.54,2456826.5,89.65805,20.06885,5.54,2456827.0,89.35929,19.95328,5.54,2456827.5,89.05805,19.84139,5.54,2456828.0,88.75560,19.73342,5.54,2456828.5,88.45321,19.62963,5.54,2456829.0,88.15213,19.53027,5.54,2456829.5,87.85363,19.43557,5.54,2456830.0,87.55895,19.34576,5.54,2456830.5,87.26932,19.26105,5.45,2456831.0,86.98594,19.18164,5.28,2456831.5,86.70999,19.10774,5.10,2456832.0,86.44260,19.03950,4.92,2456832.5,86.18487,18.97708,4.74,2456833.0,85.93786,18.92064,4.55,2456833.5,85.70259,18.87029,4.37,2456834.0,85.48002,18.82613,4.19,2456834.5,85.27107,18.78825,4.01,2456835.0,85.07661,18.75673,3.84,2456835.5,84.89745,18.73159,3.67,2456836.0,84.73436,18.71288,3.51,2456836.5,84.58804,18.70059,3.34,2456837.0,84.45916,18.69472,3.19,2456837.5,84.34832,18.69524,3.04,2456838.0,84.25607,18.70209,2.89,2456838.5,84.18293,18.71521,2.75,2456839.0,84.12936,18.73452,2.61,2456839.5,84.09577,18.75991,2.48]],
    +		["V","rgb(245,222,179)",[2456474.5,126.71878,20.82026,-3.85,2456475.5,127.97871,20.53742,-3.85,2456476.5,129.23341,20.24516,-3.85,2456477.5,130.48282,19.94367,-3.85,2456478.5,131.72688,19.63312,-3.85,2456479.5,132.96554,19.31371,-3.85,2456480.5,134.19874,18.98562,-3.85,2456481.5,135.42647,18.64905,-3.85,2456482.5,136.64869,18.30418,-3.85,2456483.5,137.86538,17.95121,-3.86,2456484.5,139.07654,17.59034,-3.86,2456485.5,140.28217,17.22176,-3.86,2456486.5,141.48229,16.84567,-3.86,2456487.5,142.67692,16.46228,-3.86,2456488.5,143.86608,16.07177,-3.86,2456489.5,145.04983,15.67436,-3.86,2456490.5,146.22821,15.27024,-3.87,2456491.5,147.40128,14.85962,-3.87,2456492.5,148.56911,14.44269,-3.87,2456493.5,149.73176,14.01966,-3.87,2456494.5,150.88932,13.59072,-3.87,2456495.5,152.04186,13.15609,-3.88,2456496.5,153.18948,12.71596,-3.88,2456497.5,154.33228,12.27052,-3.88,2456498.5,155.47040,11.81996,-3.88,2456499.5,156.60394,11.36448,-3.88,2456500.5,157.73305,10.90426,-3.89,2456501.5,158.85787,10.43951,-3.89,2456502.5,159.97852,9.97041,-3.89,2456503.5,161.09514,9.49716,-3.89,2456504.5,162.20788,9.01995,-3.90,2456505.5,163.31686,8.53898,-3.90,2456506.5,164.42222,8.05445,-3.90,2456507.5,165.52409,7.56655,-3.91,2456508.5,166.62262,7.07547,-3.91,2456509.5,167.71794,6.58142,-3.91,2456510.5,168.81020,6.08458,-3.91,2456511.5,169.89952,5.58515,-3.92,2456512.5,170.98606,5.08332,-3.92,2456513.5,172.06997,4.57929,-3.92,2456514.5,173.15138,4.07324,-3.93,2456515.5,174.23044,3.56537,-3.93,2456516.5,175.30731,3.05586,-3.94,2456517.5,176.38212,2.54491,-3.94,2456518.5,177.45503,2.03270,-3.94,2456519.5,178.52619,1.51943,-3.95,2456520.5,179.59574,1.00528,-3.95,2456521.5,180.66382,0.49043,-3.95,2456522.5,181.73059,-0.02491,-3.96,2456523.5,182.79619,-0.54058,-3.96,2456524.5,183.86077,-1.05637,-3.97,2456525.5,184.92449,-1.57212,-3.97,2456526.5,185.98754,-2.08764,-3.98,2456527.5,187.05007,-2.60277,-3.98,2456528.5,188.11227,-3.11731,-3.99,2456529.5,189.17431,-3.63110,-3.99,2456530.5,190.23636,-4.14396,-4.00,2456531.5,191.29858,-4.65570,-4.00,2456532.5,192.36113,-5.16616,-4.01,2456533.5,193.42416,-5.67514,-4.01,2456534.5,194.48782,-6.18246,-4.02,2456535.5,195.55225,-6.68794,-4.02,2456536.5,196.61761,-7.19139,-4.03,2456537.5,197.68402,-7.69264,-4.03,2456538.5,198.75162,-8.19148,-4.04,2456539.5,199.82053,-8.68775,-4.04,2456540.5,200.89090,-9.18124,-4.05,2456541.5,201.96283,-9.67178,-4.05,2456542.5,203.03645,-10.15917,-4.06,2456543.5,204.11186,-10.64323,-4.07,2456544.5,205.18918,-11.12377,-4.07,2456545.5,206.26850,-11.60061,-4.08,2456546.5,207.34991,-12.07355,-4.08,2456547.5,208.43349,-12.54241,-4.09,2456548.5,209.51931,-13.00700,-4.10,2456549.5,210.60745,-13.46712,-4.10,2456550.5,211.69796,-13.92259,-4.11,2456551.5,212.79092,-14.37322,-4.12,2456552.5,213.88637,-14.81882,-4.13,2456553.5,214.98440,-15.25920,-4.13,2456554.5,216.08507,-15.69418,-4.14,2456555.5,217.18844,-16.12359,-4.15,2456556.5,218.29458,-16.54723,-4.15,2456557.5,219.40354,-16.96494,-4.16,2456558.5,220.51536,-17.37654,-4.17,2456559.5,221.63008,-17.78185,-4.18,2456560.5,222.74772,-18.18069,-4.18,2456561.5,223.86829,-18.57288,-4.19,2456562.5,224.99178,-18.95825,-4.20,2456563.5,226.11818,-19.33662,-4.21,2456564.5,227.24748,-19.70781,-4.22,2456565.5,228.37963,-20.07166,-4.22,2456566.5,229.51459,-20.42799,-4.23,2456567.5,230.65230,-20.77664,-4.24,2456568.5,231.79269,-21.11743,-4.25,2456569.5,232.93566,-21.45021,-4.26,2456570.5,234.08114,-21.77481,-4.27,2456571.5,235.22898,-22.09107,-4.28,2456572.5,236.37907,-22.39886,-4.28,2456573.5,237.53124,-22.69802,-4.29,2456574.5,238.68532,-22.98840,-4.30,2456575.5,239.84111,-23.26986,-4.31,2456576.5,240.99841,-23.54227,-4.32,2456577.5,242.15698,-23.80550,-4.33,2456578.5,243.31659,-24.05941,-4.34,2456579.5,244.47699,-24.30389,-4.35,2456580.5,245.63795,-24.53882,-4.36,2456581.5,246.79920,-24.76409,-4.37,2456582.5,247.96048,-24.97960,-4.38,2456583.5,249.12153,-25.18525,-4.39,2456584.5,250.28207,-25.38097,-4.40,2456585.5,251.44182,-25.56668,-4.41,2456586.5,252.60046,-25.74231,-4.42,2456587.5,253.75770,-25.90780,-4.43,2456588.5,254.91319,-26.06309,-4.44,2456589.5,256.06660,-26.20814,-4.45,2456590.5,257.21757,-26.34291,-4.46,2456591.5,258.36573,-26.46737,-4.47,2456592.5,259.51071,-26.58151,-4.48,2456593.5,260.65212,-26.68530,-4.49,2456594.5,261.78954,-26.77876,-4.51,2456595.5,262.92257,-26.86188,-4.52,2456596.5,264.05078,-26.93468,-4.53,2456597.5,265.17373,-26.99720,-4.54,2456598.5,266.29097,-27.04947,-4.55,2456599.5,267.40203,-27.09154,-4.56,2456600.5,268.50642,-27.12347,-4.57,2456601.5,269.60363,-27.14535,-4.58,2456602.5,270.69311,-27.15726,-4.59,2456603.5,271.77432,-27.15928,-4.61,2456604.5,272.84669,-27.15153,-4.62,2456605.5,273.90963,-27.13411,-4.63,2456606.5,274.96256,-27.10715,-4.64,2456607.5,276.00490,-27.07078,-4.65,2456608.5,277.03604,-27.02513,-4.66,2456609.5,278.05541,-26.97035,-4.67,2456610.5,279.06242,-26.90660,-4.68,2456611.5,280.05646,-26.83404,-4.70,2456612.5,281.03695,-26.75286,-4.71,2456613.5,282.00326,-26.66324,-4.72,2456614.5,282.95479,-26.56538,-4.73,2456615.5,283.89092,-26.45947,-4.74,2456616.5,284.81100,-26.34573,-4.75,2456617.5,285.71440,-26.22437,-4.76,2456618.5,286.60045,-26.09563,-4.77,2456619.5,287.46849,-25.95973,-4.78,2456620.5,288.31784,-25.81692,-4.79,2456621.5,289.14781,-25.66744,-4.80,2456622.5,289.95767,-25.51154,-4.81,2456623.5,290.74670,-25.34951,-4.82,2456624.5,291.51415,-25.18159,-4.83,2456625.5,292.25927,-25.00807,-4.84,2456626.5,292.98125,-24.82924,-4.84,2456627.5,293.67929,-24.64539,-4.85,2456628.5,294.35252,-24.45683,-4.86,2456629.5,295.00006,-24.26388,-4.86,2456630.5,295.62098,-24.06684,-4.87,2456631.5,296.21429,-23.86606,-4.87,2456632.5,296.77899,-23.66187,-4.88,2456633.5,297.31405,-23.45459,-4.88,2456634.5,297.81842,-23.24456,-4.89,2456635.5,298.29105,-23.03212,-4.89,2456636.5,298.73087,-22.81760,-4.89,2456637.5,299.13684,-22.60133,-4.89,2456638.5,299.50790,-22.38364,-4.89,2456639.5,299.84301,-22.16488,-4.88,2456640.5,300.14113,-21.94536,-4.88,2456641.5,300.40128,-21.72542,-4.87,2456642.5,300.62246,-21.50538,-4.87,2456643.5,300.80376,-21.28557,-4.86,2456644.5,300.94430,-21.06629,-4.85,2456645.5,301.04326,-20.84786,-4.83,2456646.5,301.09992,-20.63058,-4.82,2456647.5,301.11366,-20.41475,-4.80,2456648.5,301.08396,-20.20066,-4.78,2456649.5,301.01045,-19.98860,-4.76,2456650.5,300.89292,-19.77884,-4.73,2456651.5,300.73133,-19.57166,-4.71,2456652.5,300.52585,-19.36733,-4.68,2456653.5,300.27686,-19.16610,-4.64,2456654.5,299.98499,-18.96825,-4.60,2456655.5,299.65112,-18.77402,-4.56,2456656.5,299.27640,-18.58368,-4.52,2456657.5,298.86226,-18.39750,-4.47,2456658.5,298.41043,-18.21573,-4.42,2456659.5,297.92292,-18.03864,-4.37,2456660.5,297.40208,-17.86649,-4.31,2456661.5,296.85059,-17.69956,-4.26,2456662.5,296.27145,-17.53809,-4.25,2456663.5,295.66800,-17.38234,-4.28,2456664.5,295.04390,-17.23258,-4.31,2456665.5,294.40305,-17.08905,-4.34,2456666.5,293.74962,-16.95201,-4.34,2456667.5,293.08793,-16.82170,-4.34,2456668.5,292.42243,-16.69835,-4.34,2456669.5,291.75763,-16.58220,-4.34,2456670.5,291.09805,-16.47344,-4.34,2456671.5,290.44814,-16.37227,-4.34,2456672.5,289.81221,-16.27885,-4.31,2456673.5,289.19442,-16.19332,-4.28,2456674.5,288.59869,-16.11576,-4.25,2456675.5,288.02867,-16.04625,-4.23,2456676.5,287.48769,-15.98479,-4.29,2456677.5,286.97879,-15.93137,-4.35,2456678.5,286.50462,-15.88590,-4.40,2456679.5,286.06748,-15.84828,-4.45,2456680.5,285.66933,-15.81835,-4.50,2456681.5,285.31176,-15.79588,-4.54,2456682.5,284.99602,-15.78065,-4.58,2456683.5,284.72302,-15.77237,-4.62,2456684.5,284.49337,-15.77071,-4.65,2456685.5,284.30739,-15.77533,-4.68,2456686.5,284.16514,-15.78584,-4.71,2456687.5,284.06646,-15.80185,-4.73,2456688.5,284.01097,-15.82291,-4.75,2456689.5,283.99819,-15.84860,-4.77,2456690.5,284.02746,-15.87843,-4.79,2456691.5,284.09808,-15.91194,-4.81,2456692.5,284.20922,-15.94865,-4.82,2456693.5,284.36004,-15.98807,-4.83,2456694.5,284.54959,-16.02971,-4.84,2456695.5,284.77691,-16.07308,-4.85,2456696.5,285.04098,-16.11770,-4.85,2456697.5,285.34078,-16.16309,-4.86,2456698.5,285.67523,-16.20877,-4.86,2456699.5,286.04327,-16.25428,-4.86,2456700.5,286.44381,-16.29916,-4.86,2456701.5,286.87579,-16.34297,-4.86,2456702.5,287.33813,-16.38528,-4.86,2456703.5,287.82977,-16.42566,-4.86,2456704.5,288.34966,-16.46373,-4.85,2456705.5,288.89676,-16.49907,-4.85,2456706.5,289.47005,-16.53133,-4.84,2456707.5,290.06853,-16.56015,-4.84,2456708.5,290.69121,-16.58517,-4.83,2456709.5,291.33713,-16.60609,-4.83,2456710.5,292.00533,-16.62258,-4.82,2456711.5,292.69487,-16.63437,-4.81,2456712.5,293.40483,-16.64118,-4.80,2456713.5,294.13430,-16.64275,-4.79,2456714.5,294.88241,-16.63884,-4.78,2456715.5,295.64827,-16.62923,-4.78,2456716.5,296.43105,-16.61370,-4.77,2456717.5,297.22995,-16.59204,-4.76,2456718.5,298.04420,-16.56408,-4.75,2456719.5,298.87307,-16.52962,-4.74,2456720.5,299.71587,-16.48851,-4.73,2456721.5,300.57193,-16.44060,-4.72,2456722.5,301.44063,-16.38574,-4.71,2456723.5,302.32134,-16.32379,-4.69,2456724.5,303.21350,-16.25465,-4.68,2456725.5,304.11653,-16.17820,-4.67,2456726.5,305.02990,-16.09434,-4.66,2456727.5,305.95307,-16.00298,-4.65,2456728.5,306.88555,-15.90405,-4.64,2456729.5,307.82685,-15.79748,-4.63,2456730.5,308.77651,-15.68321,-4.62,2456731.5,309.73406,-15.56119,-4.61,2456732.5,310.69909,-15.43140,-4.59,2456733.5,311.67118,-15.29379,-4.58,2456734.5,312.64992,-15.14836,-4.57,2456735.5,313.63493,-14.99510,-4.56,2456736.5,314.62583,-14.83401,-4.55,2456737.5,315.62226,-14.66511,-4.54,2456738.5,316.62386,-14.48843,-4.53,2456739.5,317.63027,-14.30399,-4.52,2456740.5,318.64116,-14.11186,-4.51,2456741.5,319.65619,-13.91208,-4.49,2456742.5,320.67503,-13.70472,-4.48,2456743.5,321.69737,-13.48985,-4.47,2456744.5,322.72290,-13.26754,-4.46,2456745.5,323.75136,-13.03788,-4.45,2456746.5,324.78247,-12.80094,-4.44,2456747.5,325.81600,-12.55683,-4.43,2456748.5,326.85172,-12.30564,-4.42,2456749.5,327.88943,-12.04747,-4.41,2456750.5,328.92893,-11.78242,-4.40,2456751.5,329.97005,-11.51061,-4.39,2456752.5,331.01262,-11.23213,-4.38,2456753.5,332.05650,-10.94712,-4.37,2456754.5,333.10156,-10.65568,-4.36,2456755.5,334.14767,-10.35795,-4.35,2456756.5,335.19472,-10.05403,-4.34,2456757.5,336.24263,-9.74407,-4.33,2456758.5,337.29130,-9.42818,-4.32,2456759.5,338.34067,-9.10652,-4.31,2456760.5,339.39067,-8.77920,-4.30,2456761.5,340.44125,-8.44637,-4.29,2456762.5,341.49238,-8.10817,-4.28,2456763.5,342.54402,-7.76474,-4.27,2456764.5,343.59615,-7.41624,-4.26,2456765.5,344.64874,-7.06281,-4.25,2456766.5,345.70176,-6.70462,-4.25,2456767.5,346.75521,-6.34182,-4.24,2456768.5,347.80906,-5.97458,-4.23,2456769.5,348.86330,-5.60307,-4.22,2456770.5,349.91791,-5.22745,-4.21,2456771.5,350.97288,-4.84791,-4.20,2456772.5,352.02822,-4.46460,-4.19,2456773.5,353.08394,-4.07771,-4.19,2456774.5,354.14006,-3.68741,-4.18,2456775.5,355.19659,-3.29387,-4.17,2456776.5,356.25356,-2.89726,-4.16,2456777.5,357.31102,-2.49777,-4.15,2456778.5,358.36901,-2.09555,-4.15,2456779.5,359.42757,-1.69080,-4.14,2456780.5,0.48677,-1.28368,-4.13,2456781.5,1.54665,-0.87436,-4.12,2456782.5,2.60730,-0.46303,-4.12,2456783.5,3.66879,-0.04986,-4.11,2456784.5,4.73121,0.36498,-4.10,2456785.5,5.79463,0.78131,-4.10,2456786.5,6.85916,1.19897,-4.09,2456787.5,7.92490,1.61777,-4.08,2456788.5,8.99194,2.03754,-4.08,2456789.5,10.06040,2.45810,-4.07,2456790.5,11.13040,2.87930,-4.06,2456791.5,12.20205,3.30094,-4.06,2456792.5,13.27546,3.72285,-4.05,2456793.5,14.35075,4.14485,-4.04,2456794.5,15.42804,4.56677,-4.04,2456795.5,16.50741,4.98842,-4.03,2456796.5,17.58899,5.40961,-4.03,2456797.5,18.67286,5.83015,-4.02,2456798.5,19.75913,6.24985,-4.01,2456799.5,20.84789,6.66853,-4.01,2456800.5,21.93926,7.08600,-4.00,2456801.5,23.03332,7.50206,-4.00,2456802.5,24.13017,7.91652,-3.99,2456803.5,25.22992,8.32920,-3.99,2456804.5,26.33265,8.73991,-3.98,2456805.5,27.43846,9.14846,-3.98,2456806.5,28.54743,9.55465,-3.97,2456807.5,29.65966,9.95829,-3.97,2456808.5,30.77524,10.35919,-3.96,2456809.5,31.89425,10.75716,-3.96,2456810.5,33.01679,11.15200,-3.95,2456811.5,34.14295,11.54353,-3.95,2456812.5,35.27282,11.93155,-3.94,2456813.5,36.40650,12.31588,-3.94,2456814.5,37.54407,12.69631,-3.94,2456815.5,38.68562,13.07267,-3.93,2456816.5,39.83126,13.44475,-3.93,2456817.5,40.98105,13.81238,-3.92,2456818.5,42.13509,14.17535,-3.92,2456819.5,43.29347,14.53349,-3.92,2456820.5,44.45625,14.88660,-3.91,2456821.5,45.62351,15.23449,-3.91,2456822.5,46.79531,15.57698,-3.91,2456823.5,47.97170,15.91387,-3.90,2456824.5,49.15272,16.24497,-3.90,2456825.5,50.33841,16.57008,-3.90,2456826.5,51.52880,16.88900,-3.89,2456827.5,52.72391,17.20156,-3.89,2456828.5,53.92376,17.50755,-3.89,2456829.5,55.12835,17.80680,-3.89,2456830.5,56.33768,18.09910,-3.88,2456831.5,57.55174,18.38429,-3.88,2456832.5,58.77049,18.66217,-3.88,2456833.5,59.99390,18.93257,-3.87,2456834.5,61.22192,19.19531,-3.87,2456835.5,62.45450,19.45020,-3.87,2456836.5,63.69159,19.69708,-3.87,2456837.5,64.93311,19.93578,-3.87,2456838.5,66.17899,20.16611,-3.86,2456839.5,67.42916,20.38792,-3.86]],
    +		["Ma","rgb(255,50,50)",[2456474.5,80.64099,23.54688,1.53,2456475.5,81.38904,23.59991,1.53,2456476.5,82.13678,23.64934,1.53,2456477.5,82.88417,23.69519,1.54,2456478.5,83.63116,23.73746,1.54,2456479.5,84.37771,23.77615,1.54,2456480.5,85.12376,23.81128,1.55,2456481.5,85.86927,23.84284,1.55,2456482.5,86.61418,23.87085,1.55,2456483.5,87.35845,23.89531,1.56,2456484.5,88.10203,23.91623,1.56,2456485.5,88.84488,23.93362,1.56,2456486.5,89.58696,23.94749,1.57,2456487.5,90.32823,23.95785,1.57,2456488.5,91.06864,23.96471,1.57,2456489.5,91.80818,23.96807,1.57,2456490.5,92.54680,23.96797,1.58,2456491.5,93.28448,23.96441,1.58,2456492.5,94.02118,23.95740,1.58,2456493.5,94.75687,23.94698,1.59,2456494.5,95.49152,23.93314,1.59,2456495.5,96.22510,23.91592,1.59,2456496.5,96.95756,23.89532,1.59,2456497.5,97.68888,23.87136,1.60,2456498.5,98.41903,23.84405,1.60,2456499.5,99.14799,23.81341,1.60,2456500.5,99.87574,23.77946,1.60,2456501.5,100.60225,23.74222,1.61,2456502.5,101.32750,23.70172,1.61,2456503.5,102.05144,23.65797,1.61,2456504.5,102.77405,23.61100,1.61,2456505.5,103.49529,23.56083,1.61,2456506.5,104.21513,23.50750,1.62,2456507.5,104.93351,23.45103,1.62,2456508.5,105.65041,23.39144,1.62,2456509.5,106.36579,23.32876,1.62,2456510.5,107.07960,23.26302,1.62,2456511.5,107.79182,23.19423,1.62,2456512.5,108.50241,23.12244,1.63,2456513.5,109.21134,23.04766,1.63,2456514.5,109.91858,22.96992,1.63,2456515.5,110.62411,22.88924,1.63,2456516.5,111.32791,22.80566,1.63,2456517.5,112.02995,22.71921,1.63,2456518.5,112.73022,22.62990,1.63,2456519.5,113.42871,22.53778,1.64,2456520.5,114.12539,22.44288,1.64,2456521.5,114.82025,22.34522,1.64,2456522.5,115.51327,22.24484,1.64,2456523.5,116.20444,22.14176,1.64,2456524.5,116.89374,22.03602,1.64,2456525.5,117.58115,21.92763,1.64,2456526.5,118.26669,21.81663,1.64,2456527.5,118.95035,21.70304,1.64,2456528.5,119.63212,21.58690,1.64,2456529.5,120.31201,21.46823,1.64,2456530.5,120.99000,21.34708,1.64,2456531.5,121.66609,21.22346,1.64,2456532.5,122.34026,21.09743,1.65,2456533.5,123.01250,20.96901,1.65,2456534.5,123.68278,20.83825,1.65,2456535.5,124.35109,20.70517,1.65,2456536.5,125.01742,20.56981,1.65,2456537.5,125.68175,20.43220,1.65,2456538.5,126.34406,20.29239,1.65,2456539.5,127.00434,20.15041,1.65,2456540.5,127.66258,20.00628,1.65,2456541.5,128.31876,19.86005,1.64,2456542.5,128.97288,19.71175,1.64,2456543.5,129.62494,19.56141,1.64,2456544.5,130.27493,19.40907,1.64,2456545.5,130.92284,19.25476,1.64,2456546.5,131.56870,19.09852,1.64,2456547.5,132.21247,18.94038,1.64,2456548.5,132.85418,18.78037,1.64,2456549.5,133.49382,18.61854,1.64,2456550.5,134.13138,18.45492,1.64,2456551.5,134.76687,18.28953,1.64,2456552.5,135.40030,18.12241,1.64,2456553.5,136.03168,17.95359,1.63,2456554.5,136.66102,17.78310,1.63,2456555.5,137.28835,17.61097,1.63,2456556.5,137.91368,17.43723,1.63,2456557.5,138.53701,17.26191,1.63,2456558.5,139.15837,17.08505,1.63,2456559.5,139.77776,16.90668,1.62,2456560.5,140.39517,16.72685,1.62,2456561.5,141.01061,16.54559,1.62,2456562.5,141.62407,16.36293,1.62,2456563.5,142.23556,16.17892,1.62,2456564.5,142.84507,15.99359,1.61,2456565.5,143.45259,15.80697,1.61,2456566.5,144.05813,15.61911,1.61,2456567.5,144.66168,15.43003,1.61,2456568.5,145.26325,15.23978,1.60,2456569.5,145.86284,15.04839,1.60,2456570.5,146.46044,14.85589,1.60,2456571.5,147.05606,14.66231,1.59,2456572.5,147.64971,14.46770,1.59,2456573.5,148.24140,14.27208,1.59,2456574.5,148.83112,14.07550,1.58,2456575.5,149.41887,13.87798,1.58,2456576.5,150.00468,13.67957,1.58,2456577.5,150.58852,13.48029,1.57,2456578.5,151.17043,13.28018,1.57,2456579.5,151.75040,13.07926,1.57,2456580.5,152.32845,12.87757,1.56,2456581.5,152.90460,12.67513,1.56,2456582.5,153.47887,12.47198,1.55,2456583.5,154.05129,12.26813,1.55,2456584.5,154.62188,12.06362,1.55,2456585.5,155.19064,11.85849,1.54,2456586.5,155.75759,11.65276,1.54,2456587.5,156.32274,11.44647,1.53,2456588.5,156.88609,11.23965,1.53,2456589.5,157.44765,11.03234,1.52,2456590.5,158.00740,10.82457,1.52,2456591.5,158.56536,10.61638,1.51,2456592.5,159.12152,10.40781,1.51,2456593.5,159.67588,10.19888,1.50,2456594.5,160.22843,9.98964,1.50,2456595.5,160.77918,9.78012,1.49,2456596.5,161.32811,9.57035,1.48,2456597.5,161.87524,9.36037,1.48,2456598.5,162.42055,9.15020,1.47,2456599.5,162.96404,8.93989,1.47,2456600.5,163.50572,8.72946,1.46,2456601.5,164.04558,8.51895,1.45,2456602.5,164.58361,8.30839,1.45,2456603.5,165.11980,8.09783,1.44,2456604.5,165.65415,7.88729,1.43,2456605.5,166.18665,7.67679,1.43,2456606.5,166.71732,7.46639,1.42,2456607.5,167.24614,7.25609,1.41,2456608.5,167.77315,7.04592,1.41,2456609.5,168.29833,6.83592,1.40,2456610.5,168.82172,6.62611,1.39,2456611.5,169.34332,6.41651,1.38,2456612.5,169.86314,6.20716,1.37,2456613.5,170.38117,5.99807,1.37,2456614.5,170.89743,5.78929,1.36,2456615.5,171.41190,5.58084,1.35,2456616.5,171.92458,5.37276,1.34,2456617.5,172.43546,5.16509,1.33,2456618.5,172.94452,4.95785,1.32,2456619.5,173.45175,4.75107,1.32,2456620.5,173.95713,4.54480,1.31,2456621.5,174.46065,4.33907,1.30,2456622.5,174.96229,4.13391,1.29,2456623.5,175.46203,3.92935,1.28,2456624.5,175.95985,3.72543,1.27,2456625.5,176.45573,3.52218,1.26,2456626.5,176.94965,3.31963,1.25,2456627.5,177.44158,3.11782,1.24,2456628.5,177.93150,2.91679,1.23,2456629.5,178.41938,2.71655,1.22,2456630.5,178.90518,2.51716,1.21,2456631.5,179.38887,2.31864,1.20,2456632.5,179.87042,2.12102,1.19,2456633.5,180.34980,1.92435,1.18,2456634.5,180.82700,1.72864,1.16,2456635.5,181.30200,1.53393,1.15,2456636.5,181.77478,1.34023,1.14,2456637.5,182.24535,1.14757,1.13,2456638.5,182.71367,0.95598,1.12,2456639.5,183.17974,0.76549,1.11,2456640.5,183.64354,0.57611,1.09,2456641.5,184.10504,0.38789,1.08,2456642.5,184.56420,0.20085,1.07,2456643.5,185.02100,0.01502,1.06,2456644.5,185.47540,-0.16957,1.04,2456645.5,185.92735,-0.35288,1.03,2456646.5,186.37681,-0.53487,1.02,2456647.5,186.82373,-0.71552,1.00,2456648.5,187.26806,-0.89479,0.99,2456649.5,187.70975,-1.07264,0.98,2456650.5,188.14875,-1.24905,0.96,2456651.5,188.58500,-1.42397,0.95,2456652.5,189.01844,-1.59738,0.93,2456653.5,189.44901,-1.76923,0.92,2456654.5,189.87664,-1.93950,0.90,2456655.5,190.30127,-2.10815,0.89,2456656.5,190.72282,-2.27513,0.87,2456657.5,191.14121,-2.44043,0.86,2456658.5,191.55635,-2.60400,0.84,2456659.5,191.96817,-2.76579,0.83,2456660.5,192.37658,-2.92579,0.81,2456661.5,192.78151,-3.08395,0.79,2456662.5,193.18290,-3.24024,0.78,2456663.5,193.58068,-3.39464,0.76,2456664.5,193.97479,-3.54713,0.74,2456665.5,194.36518,-3.69767,0.73,2456666.5,194.75177,-3.84624,0.71,2456667.5,195.13450,-3.99281,0.69,2456668.5,195.51329,-4.13736,0.67,2456669.5,195.88806,-4.27984,0.66,2456670.5,196.25872,-4.42024,0.64,2456671.5,196.62518,-4.55851,0.62,2456672.5,196.98733,-4.69461,0.60,2456673.5,197.34509,-4.82853,0.58,2456674.5,197.69833,-4.96021,0.56,2456675.5,198.04695,-5.08961,0.54,2456676.5,198.39083,-5.21672,0.52,2456677.5,198.72986,-5.34147,0.51,2456678.5,199.06392,-5.46384,0.49,2456679.5,199.39286,-5.58379,0.46,2456680.5,199.71657,-5.70128,0.44,2456681.5,200.03490,-5.81626,0.42,2456682.5,200.34772,-5.92871,0.40,2456683.5,200.65486,-6.03857,0.38,2456684.5,200.95618,-6.14581,0.36,2456685.5,201.25151,-6.25038,0.34,2456686.5,201.54068,-6.35224,0.32,2456687.5,201.82354,-6.45135,0.29,2456688.5,202.09991,-6.54766,0.27,2456689.5,202.36965,-6.64114,0.25,2456690.5,202.63261,-6.73175,0.23,2456691.5,202.88865,-6.81946,0.20,2456692.5,203.13764,-6.90424,0.18,2456693.5,203.37944,-6.98606,0.16,2456694.5,203.61390,-7.06488,0.13,2456695.5,203.84087,-7.14067,0.11,2456696.5,204.06020,-7.21341,0.08,2456697.5,204.27172,-7.28304,0.06,2456698.5,204.47527,-7.34953,0.04,2456699.5,204.67067,-7.41284,0.01,2456700.5,204.85775,-7.47293,-0.01,2456701.5,205.03632,-7.52976,-0.04,2456702.5,205.20621,-7.58328,-0.07,2456703.5,205.36722,-7.63346,-0.09,2456704.5,205.51917,-7.68024,-0.12,2456705.5,205.66187,-7.72359,-0.14,2456706.5,205.79512,-7.76346,-0.17,2456707.5,205.91872,-7.79981,-0.20,2456708.5,206.03246,-7.83259,-0.22,2456709.5,206.13616,-7.86176,-0.25,2456710.5,206.22960,-7.88728,-0.28,2456711.5,206.31258,-7.90910,-0.31,2456712.5,206.38489,-7.92718,-0.33,2456713.5,206.44633,-7.94148,-0.36,2456714.5,206.49670,-7.95196,-0.39,2456715.5,206.53581,-7.95858,-0.42,2456716.5,206.56349,-7.96130,-0.45,2456717.5,206.57960,-7.96010,-0.48,2456718.5,206.58401,-7.95496,-0.50,2456719.5,206.57660,-7.94586,-0.53,2456720.5,206.55728,-7.93280,-0.56,2456721.5,206.52597,-7.91576,-0.59,2456722.5,206.48259,-7.89473,-0.62,2456723.5,206.42707,-7.86973,-0.65,2456724.5,206.35935,-7.84074,-0.68,2456725.5,206.27938,-7.80777,-0.71,2456726.5,206.18713,-7.77081,-0.73,2456727.5,206.08257,-7.72989,-0.76,2456728.5,205.96569,-7.68501,-0.79,2456729.5,205.83651,-7.63620,-0.82,2456730.5,205.69505,-7.58347,-0.85,2456731.5,205.54135,-7.52685,-0.88,2456732.5,205.37548,-7.46639,-0.91,2456733.5,205.19752,-7.40212,-0.94,2456734.5,205.00758,-7.33410,-0.97,2456735.5,204.80578,-7.26238,-0.99,2456736.5,204.59228,-7.18704,-1.02,2456737.5,204.36725,-7.10815,-1.05,2456738.5,204.13089,-7.02580,-1.08,2456739.5,203.88343,-6.94008,-1.10,2456740.5,203.62513,-6.85109,-1.13,2456741.5,203.35629,-6.75897,-1.16,2456742.5,203.07723,-6.66382,-1.18,2456743.5,202.78835,-6.56581,-1.21,2456744.5,202.49005,-6.46509,-1.24,2456745.5,202.18281,-6.36183,-1.26,2456746.5,201.86714,-6.25622,-1.29,2456747.5,201.54359,-6.14848,-1.31,2456748.5,201.21273,-6.03880,-1.33,2456749.5,200.87516,-5.92742,-1.36,2456750.5,200.53151,-5.81456,-1.38,2456751.5,200.18242,-5.70045,-1.40,2456752.5,199.82854,-5.58535,-1.42,2456753.5,199.47053,-5.46948,-1.44,2456754.5,199.10907,-5.35311,-1.46,2456755.5,198.74484,-5.23646,-1.47,2456756.5,198.37853,-5.11982,-1.48,2456757.5,198.01083,-5.00341,-1.48,2456758.5,197.64243,-4.88751,-1.48,2456759.5,197.27404,-4.77236,-1.47,2456760.5,196.90633,-4.65822,-1.46,2456761.5,196.54000,-4.54534,-1.45,2456762.5,196.17570,-4.43396,-1.44,2456763.5,195.81410,-4.32433,-1.43,2456764.5,195.45585,-4.21670,-1.41,2456765.5,195.10158,-4.11128,-1.40,2456766.5,194.75190,-4.00830,-1.38,2456767.5,194.40742,-3.90799,-1.37,2456768.5,194.06872,-3.81056,-1.35,2456769.5,193.73639,-3.71621,-1.34,2456770.5,193.41097,-3.62515,-1.32,2456771.5,193.09304,-3.53756,-1.30,2456772.5,192.78312,-3.45364,-1.29,2456773.5,192.48174,-3.37356,-1.27,2456774.5,192.18940,-3.29750,-1.25,2456775.5,191.90655,-3.22561,-1.23,2456776.5,191.63365,-3.15804,-1.21,2456777.5,191.37109,-3.09491,-1.19,2456778.5,191.11924,-3.03634,-1.17,2456779.5,190.87841,-2.98243,-1.15,2456780.5,190.64888,-2.93327,-1.13,2456781.5,190.43092,-2.88892,-1.11,2456782.5,190.22473,-2.84944,-1.09,2456783.5,190.03048,-2.81488,-1.06,2456784.5,189.84834,-2.78528,-1.04,2456785.5,189.67842,-2.76065,-1.02,2456786.5,189.52082,-2.74101,-1.00,2456787.5,189.37559,-2.72636,-0.98,2456788.5,189.24278,-2.71671,-0.96,2456789.5,189.12242,-2.71204,-0.93,2456790.5,189.01449,-2.71232,-0.91,2456791.5,188.91897,-2.71754,-0.89,2456792.5,188.83582,-2.72766,-0.87,2456793.5,188.76497,-2.74265,-0.84,2456794.5,188.70637,-2.76245,-0.82,2456795.5,188.65992,-2.78703,-0.80,2456796.5,188.62554,-2.81633,-0.78,2456797.5,188.60315,-2.85032,-0.76,2456798.5,188.59266,-2.88893,-0.73,2456799.5,188.59398,-2.93212,-0.71,2456800.5,188.60704,-2.97984,-0.69,2456801.5,188.63173,-3.03204,-0.67,2456802.5,188.66797,-3.08867,-0.65,2456803.5,188.71565,-3.14968,-0.62,2456804.5,188.77465,-3.21499,-0.60,2456805.5,188.84484,-3.28455,-0.58,2456806.5,188.92608,-3.35829,-0.56,2456807.5,189.01823,-3.43614,-0.54,2456808.5,189.12114,-3.51803,-0.52,2456809.5,189.23463,-3.60388,-0.50,2456810.5,189.35853,-3.69360,-0.48,2456811.5,189.49269,-3.78713,-0.46,2456812.5,189.63692,-3.88439,-0.44,2456813.5,189.79105,-3.98528,-0.42,2456814.5,189.95490,-4.08974,-0.40,2456815.5,190.12829,-4.19769,-0.38,2456816.5,190.31106,-4.30905,-0.36,2456817.5,190.50301,-4.42373,-0.34,2456818.5,190.70398,-4.54166,-0.32,2456819.5,190.91380,-4.66276,-0.30,2456820.5,191.13228,-4.78696,-0.28,2456821.5,191.35925,-4.91417,-0.27,2456822.5,191.59454,-5.04431,-0.25,2456823.5,191.83800,-5.17733,-0.23,2456824.5,192.08947,-5.31313,-0.21,2456825.5,192.34882,-5.45165,-0.19,2456826.5,192.61592,-5.59283,-0.18,2456827.5,192.89067,-5.73661,-0.16,2456828.5,193.17295,-5.88293,-0.14,2456829.5,193.46266,-6.03174,-0.13,2456830.5,193.75972,-6.18296,-0.11,2456831.5,194.06401,-6.33656,-0.09,2456832.5,194.37543,-6.49246,-0.08,2456833.5,194.69389,-6.65061,-0.06,2456834.5,195.01926,-6.81094,-0.04,2456835.5,195.35145,-6.97339,-0.03,2456836.5,195.69035,-7.13791,-0.01,2456837.5,196.03584,-7.30441,-0.01,2456838.5,196.38783,-7.47285,0.02,2456839.5,196.74620,-7.64316,0.03]],
    +		["J","rgb(255,150,150)",[2456474.5,91.22580,23.22051,-1.91,2456484.5,93.69042,23.19678,-1.91,2456494.5,96.10542,23.13593,-1.92,2456504.5,98.44898,23.04153,-1.93,2456514.5,100.69851,22.91853,-1.96,2456524.5,102.82743,22.77329,-1.99,2456534.5,104.81110,22.61314,-2.02,2456544.5,106.62191,22.44687,-2.07,2456554.5,108.22942,22.28406,-2.12,2456564.5,109.60500,22.13459,-2.17,2456574.5,110.71640,22.00919,-2.23,2456584.5,111.53287,21.91768,-2.30,2456594.5,112.02888,21.86830,-2.37,2456604.5,112.18092,21.86758,-2.43,2456614.5,111.97878,21.91776,-2.50,2456624.5,111.42877,22.01666,-2.56,2456634.5,110.55452,22.15723,-2.62,2456644.5,109.40875,22.32703,-2.66,2456654.5,108.06771,22.51118,-2.69,2456664.5,106.62721,22.69384,-2.70,2456674.5,105.19885,22.86087,-2.67,2456684.5,103.88882,23.00306,-2.64,2456694.5,102.79196,23.11523,-2.58,2456704.5,101.98294,23.19611,-2.52,2456714.5,101.50553,23.24708,-2.45,2456724.5,101.38248,23.26957,-2.38,2456734.5,101.61476,23.26483,-2.31,2456744.5,102.18511,23.23278,-2.23,2456754.5,103.07011,23.17190,-2.16,2456764.5,104.23760,23.08036,-2.10,2456774.5,105.65303,22.95539,-2.04,2456784.5,107.28421,22.79405,-1.99,2456794.5,109.09615,22.59428,-1.94,2456804.5,111.05785,22.35409,-1.90,2456814.5,113.14144,22.07230,-1.86,2456824.5,115.31775,21.74897,-1.84,2456834.5,117.56260,21.38470,-1.82]],
    +		["S","rgb(200,150,150)",[2456474.5,213.43924,-10.77595,1.18,2456484.5,213.38929,-10.80570,1.23,2456494.5,213.49677,-10.89043,1.27,2456504.5,213.75872,-11.02768,1.31,2456514.5,214.17001,-11.21397,1.34,2456524.5,214.72219,-11.44448,1.36,2456534.5,215.40312,-11.71336,1.38,2456544.5,216.20117,-12.01481,1.39,2456554.5,217.10234,-12.34229,1.38,2456564.5,218.09096,-12.68900,1.37,2456574.5,219.15230,-13.04860,1.36,2456584.5,220.26942,-13.41441,1.33,2456594.5,221.42526,-13.78004,1.30,2456604.5,222.60273,-14.13941,1.28,2456614.5,223.78251,-14.48665,1.31,2456624.5,224.94632,-14.81634,1.34,2456634.5,226.07506,-15.12344,1.37,2456644.5,227.14762,-15.40312,1.38,2456654.5,228.14501,-15.65157,1.39,2456664.5,229.04744,-15.86518,1.39,2456674.5,229.83488,-16.04081,1.38,2456684.5,230.49048,-16.17633,1.36,2456694.5,230.99789,-16.27000,1.33,2456704.5,231.34423,-16.32088,1.30,2456714.5,231.52159,-16.32898,1.25,2456724.5,231.52511,-16.29505,1.21,2456734.5,231.35810,-16.22129,1.15,2456744.5,231.03051,-16.11104,1.09,2456754.5,230.55788,-15.96885,1.03,2456764.5,229.96565,-15.80136,0.97,2456774.5,229.28418,-15.61629,0.92,2456784.5,228.54825,-15.42258,0.87,2456794.5,227.79726,-15.23036,0.88,2456804.5,227.06876,-15.04966,0.93,2456814.5,226.39954,-14.89033,0.99,2456824.5,225.82256,-14.76116,1.05,2456834.5,225.36295,-14.66905,1.11]],
    +		["U","rgb(130,150,255)",[2456474.5,11.68524,4.26131,5.84,2456494.5,11.78993,4.29416,5.80,2456514.5,11.60000,4.20364,5.77,2456534.5,11.14671,4.00416,5.74,2456554.5,10.49986,3.72606,5.72,2456574.5,9.76319,3.41372,5.72,2456594.5,9.05949,3.11949,5.73,2456614.5,8.50801,2.89391,5.76,2456634.5,8.20411,2.77688,5.79,2456654.5,8.20303,2.79070,5.83,2456674.5,8.51624,2.93862,5.87,2456694.5,9.11473,3.20643,5.90,2456714.5,9.94140,3.56795,5.92,2456734.5,10.92070,3.98954,5.93,2456754.5,11.96871,4.43457,5.93,2456774.5,12.99963,4.86656,5.93,2456794.5,13.93188,5.25163,5.91,2456814.5,14.69128,5.56008,5.89,2456834.5,15.21530,5.76768,5.85]],
    +		["N","rgb(100,100,255)",[2456474.5,337.29849,-10.21336,7.86,2456494.5,336.98802,-10.34344,7.84,2456514.5,336.54551,-10.52194,7.83,2456534.5,336.03597,-10.72256,7.82,2456554.5,335.53577,-10.91563,7.83,2456574.5,335.12211,-11.07201,7.84,2456594.5,334.86154,-11.16764,7.86,2456614.5,334.79872,-11.18692,7.89,2456634.5,334.95207,-11.12412,7.91,2456654.5,335.31179,-10.98356,7.94,2456674.5,335.84461,-10.77793,7.95,2456694.5,336.49809,-10.52693,7.97,2456714.5,337.21013,-10.25415,7.97,2456734.5,337.91508,-9.98489,7.96,2456754.5,338.55008,-9.74378,7.95,2456774.5,339.05963,-9.55294,7.93,2456794.5,339.40083,-9.42944,7.91,2456814.5,339.54669,-9.38388,7.89,2456834.5,339.48931,-9.41868,7.86]]
    +	]
    +}
    diff --git a/html/allsky/virtualsky/showers.json b/html/allsky/virtualsky/showers.json
    new file mode 100755
    index 000000000..89393ef25
    --- /dev/null
    +++ b/html/allsky/virtualsky/showers.json
    @@ -0,0 +1,37 @@
    +{
    +	"_comment":"Using radiant information from IMO http://www.imo.net/calendar/2012#table6",
    +	"showers":{
    +		"QUA" : {"name":"Quadrantids","date":[[12,30],[1,10]],"pos":[[228,50],[234,48]] },
    +		"DLM" : {"name":"December Leonis Minorids","date":[[12,5],[2,5]],"pos":[[149,37],[234,48]] },
    +		"ACE" : {"name":"&alpha;-Centaurids","date":[[1,28],[2,21]],"pos":[[200,-57],[225,-63]]},
    +		"GNO" : {"name":"&gamma;-Normids","date":[[2,25],[3,22]],"pos":[[225,-51],[245,-49]]},
    +		"LYR" : {"name":"Lyrids","date":[[4,16],[4,25]],"pos":[[263,34],[269,34]]},
    +		"PPU" : {"name":"&pi;-Puppids","date":[[4,15],[4,28]],"pos":[[106,-44],[111,-45]]},
    +		"ETA" : {"name":"&eta;-Aquariids","date":[[4,19],[5,28]],"pos":[[323,-7],[353,7]]},
    +		"ELY" : {"name":"&eta;-Lyrids","date":[[5,3],[5,14]],"pos":[[283,44],[293,45]]},
    +		"JBO" : {"name":"June Bootids","date":[[6,22],[7,2]],"pos":[[223,48],[225,47]]},
    +		"CAP" : {"name":"&alpha;-Capricornids","date":[[7,3],[8,15]],"pos":[[285,-16],[318,-6]]},
    +		"SDA" : {"name":"South &delta;-Aquariids","date":[[7,12],[8,23]],"pos":[[325,-19],[356,-11]]},
    +		"PER" : {"name":"Perseids","date":[[7,17],[8,24]],"pos":[[6,50],[63,58]]},
    +		"PAU" : {"name":"Piscis Austrinids","date":[[7,15],[8,10]],"pos":[[330,-34],[352,-26]]},
    +		"KCG" : {"name":"&kappa;-Cygnids","date":[[8,3],[8,25]],"pos":[[283,58],[289,60]]},
    +		"AUR" : {"name":"&alpha;-Aurigids","date":[[8,28],[9,5]],"pos":[[85,40],[102,38]]},
    +		"SPE" : {"name":"September ε-Perseids","date":[[9,5],[9,21]],"pos":[[43,40],[59,41]]},
    +		"STA" : {"name":"Southern Taurids","date":[[9,10],[11,20]],"pos":[[12,3],[64,16]]},
    +		"ORI" : {"name":"Orionids","date":[[10,2],[11,7]],"pos":[[85,14],[105,17]]},
    +		"DAU" : {"name":"&delta;-Aurigids","date":[[10,10],[10,18]],"pos":[[82,45],[92,41]]},
    +		"DRA" : {"name":"Draconids","date":[[10,6],[10,10]],"pos":[[262,54],[262,54]]},
    +		"EGE" : {"name":"&epsilon;-Geminids","date":[[10,14],[10,27]],"pos":[[99,27],[109,27]]},
    +		"LMI" : {"name":"Leo Minorids","date":[[10,19],[10,27]],"pos":[[158,39],[168,35]]},
    +		"NTA" : {"name":"Northern Taurids","date":[[10,20],[12,10]],"pos":[[38,18],[74,24]]},
    +		"LEO" : {"name":"Leonids","date":[[11,6],[11,30]],"pos":[[147,24],[159,19]]},
    +		"AMO" : {"name":"&alpha;-Monocerotids","date":[[11,15],[11,25]],"pos":[[117,1],[117,1]]},
    +		"PHO" : {"name":"Phoenicids","date":[[11,28],[12,9]],"pos":[[14,-52],[22,-53]]},
    +		"PUP" : {"name":"Puppid/Velids","date":[[12,1],[12,15]],"pos":[[120,-45],[128,-45]]},
    +		"MON" : {"name":"Monocerotids","date":[[11,27],[12,17]],"pos":[[100,8],[100,8]]},
    +		"GEM" : {"name":"Geminids","date":[[12,7],[12,17]],"pos":[[103,33],[118,32]]},
    +		"HYD" : {"name":"&sigma;-Hydrids","date":[[12,3],[12,15]],"pos":[[122,3],[130,1]]},
    +		"COM" : {"name":"Comae Berenicids","date":[[12,12],[12,23]],"pos":[[174,19],[180,16]]},
    +		"URS" : {"name":"Ursids","date":[[12,17],[12,26]],"pos":[[217,76],[217,74]]}
    +	}
    +}
    \ No newline at end of file
    diff --git a/html/allsky/virtualsky/stars.json b/html/allsky/virtualsky/stars.json
    new file mode 100755
    index 000000000..ba6f273f6
    --- /dev/null
    +++ b/html/allsky/virtualsky/stars.json
    @@ -0,0 +1 @@
    +{"stars": [[122,4.78,0.399,-77.07],[145,5.13,0.456,-3.03],[154,4.37,0.49,-6.01],[183,5.04,0.583,-29.72],[301,4.55,0.935,-17.34],[355,4.99,1.125,-10.51],[443,4.61,1.334,-5.71],[761,5.42,2.338,-27.99],[814,5.29,2.509,-82.22],[841,5.01,2.58,46.07],[910,4.89,2.816,-15.47],[930,5.41,2.893,-27.8],[950,5.24,2.933,-35.13],[983,5.29,3.042,-17.94],[1158,5.13,3.615,-7.78],[1168,4.79,3.651,20.21],[1170,4.44,3.66,-18.93],[1366,4.61,4.273,38.68],[1473,4.51,4.582,36.79],[1686,5.16,5.28,37.97],[1708,5.18,5.38,-28.98],[1960,5.38,6.198,61.83],[2210,4.86,6.982,-33.01],[2219,5.01,7.012,17.89],[2225,5.18,7.057,44.39],[2240,5.42,7.111,-39.92],[2355,5.20,7.531,29.75],[2381,5.17,7.594,-23.79],[2472,4.76,7.854,-48.8],[2487,4.53,7.889,-62.97],[2505,4.74,7.943,54.52],[2568,5.38,8.148,20.29],[2578,5.07,8.183,-63.03],[2599,4.17,8.25,62.93],[2762,5.20,8.812,-3.59],[2854,5.08,9.035,54.17],[2900,5.14,9.194,44.49],[2912,4.34,9.22,33.72],[2942,5.45,9.338,35.4],[3031,4.34,9.639,29.31],[3083,5.45,9.791,49.35],[3138,5.36,9.982,21.44],[3231,5.30,10.28,39.46],[3245,4.59,10.331,-46.09],[3300,4.80,10.516,50.51],[3330,5.38,10.618,-65.47],[3405,4.36,10.838,-57.46],[3414,4.95,10.867,47.02],[3455,4.77,11.048,-10.61],[3504,4.48,11.181,48.28],[3505,5.22,11.185,-22.01],[3544,5.41,11.322,55.22],[3607,5.49,11.549,-22.52],[3632,5.36,11.637,15.48],[3693,4.08,11.835,24.27],[3721,5.42,11.942,74.85],[3781,5.09,12.148,-74.92],[3786,4.44,12.171,7.59],[3801,4.90,12.208,50.97],[3810,5.07,12.245,16.94],[3909,5.17,12.532,-10.64],[3949,5.24,12.672,-50.99],[3951,5.35,12.682,64.25],[4104,5.47,13.169,-24.01],[4147,4.78,13.252,-1.14],[4151,4.80,13.267,61.12],[4288,5.46,13.742,23.63],[4292,4.83,13.751,58.97],[4293,5.45,13.751,-69.53],[4371,5.35,14.006,-11.27],[4422,4.62,14.166,59.18],[4463,4.40,14.302,23.42],[4510,5.44,14.459,28.99],[4890,5.39,15.705,-46.4],[4914,5.40,15.761,-4.84],[5131,5.33,16.421,21.47],[5268,5.36,16.828,-61.78],[5300,5.21,16.949,-41.49],[5317,5.04,17.004,43.94],[5336,5.17,17.068,54.92],[5372,4.24,17.187,86.26],[5434,4.26,17.376,47.24],[5518,5.32,17.664,68.78],[5542,4.34,17.776,55.15],[5544,5.15,17.778,31.42],[5571,4.66,17.863,21.03],[5586,4.51,17.915,30.09],[5737,5.21,18.433,7.58],[5799,5.14,18.60,-7.92],[5862,4.97,18.796,-45.53],[5896,4.25,18.942,-68.88],[5951,5.42,19.151,-2.5],[6061,5.13,19.45,3.61],[6242,4.95,20.02,58.23],[6315,5.23,20.281,28.74],[6411,4.87,20.585,45.53],[6592,5.42,21.17,-41.49],[6670,4.90,21.405,-14.6],[6692,4.72,21.483,68.13],[6706,5.35,21.564,19.17],[6813,4.83,21.914,45.41],[6960,5.11,22.401,-21.63],[6999,5.27,22.525,47.01],[7213,5.49,23.234,-36.87],[7294,4.68,23.483,59.23],[7450,5.41,23.996,-15.4],[7513,4.10,24.199,41.41],[7650,5.28,24.629,73.04],[7719,5.01,24.838,44.39],[7818,4.96,25.145,40.58],[7918,4.96,25.446,42.61],[7955,5.25,25.536,-32.33],[7981,5.24,25.624,20.27],[7999,4.98,25.681,-3.69],[8016,5.18,25.733,70.62],[8068,4.01,25.915,50.69],[8209,5.29,26.411,-25.05],[8230,5.37,26.497,-5.73],[8240,5.49,26.525,-50.82],[8241,5.04,26.526,-53.52],[8497,4.66,27.396,-10.69],[8814,5.42,28.322,40.73],[8882,5.12,28.592,-42.5],[8928,4.68,28.734,-67.65],[9009,4.97,29.00,68.69],[9061,4.92,29.167,-22.53],[9095,4.82,29.292,-47.39],[9110,5.09,29.338,17.82],[9153,4.79,29.482,23.6],[9312,5.29,29.908,64.62],[9326,5.43,29.942,-20.82],[9372,5.43,30.112,-8.52],[9440,5.34,30.311,-30.0],[9459,5.15,30.427,-44.71],[9480,4.49,30.489,70.91],[9505,4.99,30.575,54.49],[9589,5.42,30.799,0.13],[9677,4.68,31.123,-29.3],[9727,5.27,31.281,77.28],[9763,5.22,31.381,76.12],[9836,5.03,31.641,22.65],[9977,4.78,32.122,37.86],[10053,4.98,32.356,25.94],[10280,4.94,33.093,30.3],[10306,5.23,33.20,21.21],[10320,5.27,33.227,-30.72],[10340,4.84,33.306,44.23],[10366,5.31,33.401,51.07],[10644,4.84,34.263,34.22],[10670,4.03,34.329,33.85],[10793,5.29,34.737,28.64],[10819,5.31,34.82,47.38],[11021,5.29,35.486,0.4],[11029,5.43,35.506,-10.78],[11046,5.42,35.552,-0.88],[11060,5.16,35.589,55.85],[11072,5.19,35.636,-23.82],[11220,5.19,36.104,50.01],[11249,5.48,36.204,10.61],[11258,5.36,36.225,-60.31],[11313,4.73,36.406,50.28],[11477,5.13,37.007,-33.81],[11486,5.29,37.042,29.67],[11569,4.46,37.266,67.4],[11738,5.27,37.875,2.27],[11757,5.27,37.919,-79.11],[11784,5.15,38.026,36.15],[11791,5.36,38.039,-1.03],[11918,4.96,38.461,-28.23],[12086,5.38,38.945,34.69],[12225,5.30,39.352,-52.54],[12273,5.17,39.508,72.82],[12332,5.45,39.704,21.96],[12489,5.30,40.171,27.06],[12623,4.91,40.562,40.19],[12653,5.40,40.639,-50.8],[12719,4.65,40.863,27.71],[12768,5.43,41.021,44.3],[12777,4.10,41.05,49.23],[12832,5.17,41.24,12.45],[12876,4.83,41.386,-67.62],[13061,4.52,41.977,29.25],[13141,5.25,42.256,-62.81],[13165,5.26,42.323,17.46],[13202,5.39,42.476,-27.94],[13244,4.76,42.619,-75.07],[13265,5.48,42.668,-35.68],[13288,4.76,42.76,-21.0],[13328,4.56,42.878,35.06],[13490,5.34,43.428,38.34],[13717,5.16,44.156,-3.71],[13775,5.10,44.322,31.93],[13782,5.44,44.349,-23.86],[13874,5.22,44.675,-2.78],[13879,4.68,44.69,39.66],[13884,4.98,44.699,-64.07],[13905,4.94,44.765,35.18],[13914,4.63,44.803,21.34],[13965,5.47,44.957,47.22],[14043,5.24,45.218,52.35],[14168,5.32,45.676,-7.69],[14293,5.26,46.069,-7.6],[14376,5.45,46.361,25.26],[14382,4.77,46.385,56.71],[14417,5.49,46.533,79.42],[14456,5.23,46.64,-6.09],[14632,4.05,47.267,49.61],[14817,4.61,47.822,39.61],[14838,4.35,47.907,19.73],[14862,4.85,47.984,74.39],[14954,5.07,48.193,-1.2],[15110,4.87,48.725,21.04],[15219,5.04,49.051,50.94],[15338,5.49,49.447,44.03],[15371,5.24,49.553,-62.51],[15382,4.86,49.592,-22.51],[15404,5.16,49.657,50.22],[15416,4.85,49.683,34.22],[15444,5.05,49.782,50.09],[15457,4.84,49.84,3.37],[15520,4.74,49.997,65.65],[15547,5.44,50.082,77.73],[15549,4.47,50.085,29.05],[15627,5.27,50.307,21.15],[15648,4.96,50.361,43.33],[15737,5.10,50.689,20.74],[15770,5.32,50.805,49.21],[15890,5.13,51.169,64.59],[16147,4.99,52.013,49.06],[16244,4.67,52.342,49.51],[16245,4.71,52.344,-62.94],[16281,4.55,52.478,58.88],[16292,5.09,52.501,55.45],[16322,5.14,52.602,11.34],[16335,4.36,52.644,48.0],[16341,4.74,52.654,-5.08],[16369,4.14,52.718,12.94],[16470,5.47,53.036,48.02],[16499,5.30,53.109,46.06],[16803,5.24,54.073,-17.47],[16826,4.32,54.122,48.19],[16852,4.29,54.218,0.4],[16870,4.57,54.274,-40.27],[17296,5.06,55.539,63.22],[17304,4.99,55.562,-31.94],[17313,4.97,55.594,33.97],[17351,4.59,55.709,-37.31],[17457,5.24,56.127,-1.16],[17489,5.45,56.201,24.29],[17531,4.30,56.302,24.47],[17563,5.34,56.419,6.05],[17587,4.78,56.51,63.35],[17593,4.43,56.536,-12.1],[17608,4.14,56.582,23.95],[17717,5.24,56.915,-23.87],[17771,5.08,57.068,11.14],[17776,5.44,57.087,23.42],[17851,5.05,57.297,24.14],[17854,5.40,57.307,70.87],[17884,4.39,57.38,65.53],[17886,5.14,57.386,33.09],[17954,5.24,57.579,25.58],[18141,5.48,58.174,-5.36],[18213,5.11,58.412,-34.73],[18216,4.64,58.428,-24.61],[18255,4.46,58.573,-2.95],[18396,5.39,58.992,47.87],[18434,5.49,59.12,35.08],[18453,5.28,59.152,50.7],[18488,4.99,59.285,61.11],[18673,4.62,59.981,-24.02],[18744,4.48,60.224,-62.16],[18772,4.97,60.326,-61.08],[18788,5.28,60.384,-1.55],[18859,5.38,60.653,-0.27],[18957,5.32,60.936,5.44],[18975,5.45,60.986,8.2],[18993,5.36,61.041,2.83],[19009,5.46,61.09,24.11],[19018,5.00,61.113,59.16],[19038,4.36,61.174,22.08],[19167,4.25,61.646,50.35],[19171,5.18,61.652,27.6],[19205,5.21,61.752,29.0],[19398,5.45,62.324,-16.39],[19461,5.10,62.511,80.7],[19483,5.44,62.594,-6.92],[19513,5.39,62.708,26.48],[19515,4.93,62.711,-41.99],[19587,4.04,62.966,-6.84],[19719,5.29,63.388,7.72],[19740,4.84,63.485,9.26],[19777,4.87,63.599,-10.26],[19799,5.22,63.651,10.01],[19805,5.45,63.702,-62.19],[19811,4.67,63.722,40.48],[19812,4.12,63.724,48.41],[19849,4.43,63.818,-7.65],[19860,4.27,63.884,8.89],[19949,5.20,64.18,53.61],[19990,4.93,64.315,20.58],[20070,4.60,64.561,50.3],[20156,5.46,64.805,50.05],[20161,5.33,64.82,-44.27],[20186,5.34,64.903,21.77],[20250,4.97,65.088,27.35],[20252,4.93,65.103,34.57],[20261,5.26,65.151,15.1],[20264,5.38,65.163,-20.64],[20266,5.26,65.168,65.14],[20354,4.80,65.388,46.5],[20376,5.40,65.449,60.74],[20384,5.24,65.472,-63.39],[20430,5.38,65.646,25.63],[20507,5.17,65.92,-3.75],[20522,5.10,65.966,9.46],[20542,4.80,66.024,17.44],[20635,4.21,66.342,22.29],[20641,5.27,66.354,22.2],[20704,5.29,66.526,31.44],[20711,4.28,66.577,22.81],[20713,4.48,66.586,15.62],[20732,4.69,66.652,14.71],[20776,5.42,66.762,80.82],[20877,4.96,67.11,16.36],[20901,5.02,67.209,13.05],[20982,5.47,67.502,83.34],[21029,4.78,67.64,16.19],[21036,5.40,67.656,13.72],[21039,5.47,67.662,15.69],[21139,4.91,67.969,-0.04],[21248,4.49,68.377,-29.77],[21273,4.65,68.462,14.84],[21296,5.20,68.548,-8.23],[21297,5.24,68.549,-8.97],[21402,4.25,68.914,10.16],[21476,4.25,69.173,41.26],[21515,5.32,69.307,1.0],[21547,5.22,69.401,-2.47],[21589,4.27,69.539,12.51],[21644,4.99,69.723,-12.12],[21670,5.38,69.776,7.87],[21673,5.08,69.788,15.8],[21683,4.67,69.819,15.92],[21685,5.46,69.832,-14.36],[21727,5.07,69.978,53.08],[21730,5.36,69.992,53.47],[21735,5.45,70.014,12.2],[21763,4.32,70.11,-19.67],[21914,5.30,70.693,-50.48],[21928,5.30,70.726,43.37],[22040,5.28,71.088,-59.73],[22044,5.39,71.108,11.15],[22157,5.35,71.507,11.71],[22263,5.49,71.901,-16.93],[22287,5.29,72.001,56.76],[22453,4.89,72.478,37.49],[22479,5.03,72.548,-16.22],[22565,5.08,72.844,18.84],[22626,5.47,73.022,63.51],[22667,4.71,73.133,14.25],[22678,4.79,73.158,36.7],[22833,5.18,73.695,11.43],[22834,5.33,73.699,7.78],[22871,5.47,73.797,-74.94],[22957,4.06,74.093,13.51],[23040,4.43,74.322,53.75],[23179,4.93,74.814,37.89],[23221,5.39,74.96,-10.26],[23231,4.78,74.982,-12.54],[23265,5.09,75.086,81.19],[23362,4.91,75.357,-20.05],[23364,4.80,75.36,-7.17],[23430,5.01,75.541,-26.28],[23467,5.30,75.679,-71.31],[23482,5.37,75.703,-49.15],[23497,4.62,75.774,21.59],[23522,4.03,75.855,60.44],[23595,4.55,76.102,-35.48],[23607,4.65,76.142,15.4],[23649,5.05,76.242,-49.58],[23693,4.71,76.378,-57.47],[23734,5.22,76.535,58.97],[23783,4.98,76.669,51.6],[23794,5.12,76.69,-4.66],[23835,4.91,76.863,18.65],[23840,5.19,76.892,-63.4],[23871,5.28,76.952,20.42],[23879,5.33,76.97,8.5],[23941,5.11,77.182,-4.46],[23983,5.43,77.332,9.83],[24010,4.81,77.425,15.6],[24197,5.18,77.923,16.05],[24254,5.44,78.094,73.95],[24331,4.46,78.323,2.86],[24340,4.82,78.357,38.48],[24372,4.81,78.439,-67.19],[24505,5.06,78.852,-26.94],[24504,5.01,78.852,32.69],[24659,4.81,79.371,-34.9],[24679,5.48,79.418,-13.52],[24727,4.54,79.544,33.37],[24799,5.38,79.75,33.75],[24813,4.69,79.785,40.1],[24817,5.34,79.797,2.6],[24822,4.96,79.819,22.1],[24829,5.44,79.842,-50.61],[24879,5.05,80.004,33.96],[24902,5.46,80.061,41.09],[24927,4.70,80.112,-21.24],[25044,4.72,80.441,-0.38],[25045,5.06,80.443,-24.77],[25048,5.22,80.452,41.8],[25142,4.99,80.708,3.54],[25197,5.24,80.866,57.54],[25202,5.25,80.876,-13.93],[25247,4.13,80.987,-7.81],[25278,5.00,81.106,17.38],[25282,5.07,81.12,-0.89],[25292,5.02,81.163,37.39],[25302,4.89,81.187,1.85],[25429,5.14,81.58,-58.91],[25473,4.59,81.709,3.1],[25499,5.40,81.792,17.96],[25539,4.88,81.909,21.94],[25541,5.08,81.912,34.48],[25695,5.47,82.319,25.15],[25737,4.71,82.433,-1.09],[25768,5.46,82.539,-47.08],[25769,5.43,82.543,63.07],[25813,4.20,82.696,5.95],[25861,5.46,82.811,3.29],[25923,4.62,82.983,-7.3],[25945,4.32,83.053,18.59],[25980,5.34,83.172,-1.59],[25984,4.71,83.182,32.19],[25993,5.45,83.214,-38.51],[26001,5.34,83.248,-64.23],[26063,5.36,83.381,-1.16],[26126,5.32,83.57,3.77],[26176,4.39,83.705,9.49],[26199,4.78,83.761,-6.0],[26220,4.98,83.816,-5.39],[26221,5.13,83.819,-5.39],[26235,4.98,83.845,-5.42],[26237,4.58,83.847,-4.84],[26248,5.37,83.863,24.04],[26268,5.24,83.915,-4.86],[26366,4.09,84.227,9.29],[26460,5.28,84.436,-28.69],[26536,5.40,84.659,30.49],[26563,4.77,84.721,-7.21],[26594,4.50,84.796,4.12],[26640,5.18,84.934,25.9],[26649,5.44,84.958,-32.63],[26736,4.95,85.211,-1.13],[26777,4.84,85.324,16.53],[26868,5.29,85.563,-34.67],[26885,4.90,85.619,1.47],[27196,5.46,86.475,49.83],[27204,5.18,86.50,-32.31],[27243,5.31,86.614,-46.6],[27338,5.47,86.859,17.73],[27364,5.28,86.929,13.9],[27386,5.26,87.001,6.45],[27468,4.88,87.254,24.57],[27483,4.51,87.293,39.18],[27511,4.89,87.387,12.65],[27517,5.49,87.402,-14.48],[27534,5.10,87.473,-66.9],[27566,5.46,87.57,-79.36],[27621,5.16,87.722,-52.11],[27639,4.72,87.76,37.31],[27658,5.36,87.842,-7.52],[27750,4.76,88.11,1.86],[27810,4.88,88.279,-33.8],[27830,4.56,88.332,27.61],[27947,5.29,88.709,-52.64],[27949,4.96,88.712,55.71],[27971,5.20,88.741,59.89],[28010,4.97,88.875,-37.12],[28237,4.81,89.499,25.95],[28296,5.21,89.707,0.55],[28325,5.04,89.768,-9.56],[28404,4.30,89.984,45.94],[28413,4.53,90.014,-3.07],[28574,4.92,90.46,-10.6],[28675,5.03,90.815,-26.28],[28716,4.64,90.98,20.14],[28744,5.19,91.056,-6.71],[28816,4.92,91.246,-16.48],[28943,5.46,91.634,-23.11],[28946,5.35,91.646,38.48],[28949,5.37,91.661,-4.19],[28991,5.04,91.764,-62.15],[29034,5.00,91.882,-37.25],[29048,5.28,91.923,-19.17],[29134,5.06,92.184,-68.84],[29150,5.49,92.241,-22.43],[29246,5.35,92.496,58.94],[29271,5.08,92.56,-74.75],[29276,4.72,92.575,-54.97],[29353,5.01,92.812,-65.59],[29417,5.06,92.966,-6.55],[29434,4.95,93.014,16.13],[29490,5.36,93.213,65.72],[29650,5.20,93.712,19.16],[29696,4.32,93.845,29.5],[29704,5.34,93.855,16.14],[29730,5.37,93.919,60.0],[29735,5.00,93.937,-13.72],[29736,5.44,93.937,12.55],[29800,5.04,94.111,12.27],[29850,5.39,94.278,9.94],[29895,5.15,94.424,-16.82],[29919,5.01,94.478,61.52],[29996,5.36,94.711,-9.39],[29997,4.76,94.712,69.32],[30073,5.27,94.928,-7.82],[30093,4.91,94.998,-2.94],[30214,5.48,95.353,-11.77],[30247,5.34,95.442,53.45],[30457,5.21,96.043,-11.53],[30520,4.92,96.225,49.29],[30565,5.37,96.369,-69.69],[30679,5.21,96.704,58.42],[30717,5.19,96.807,0.3],[30772,5.06,96.99,-4.76],[30788,4.47,97.043,-32.58],[30932,5.20,97.369,-56.85],[30953,5.28,97.454,-50.24],[31084,5.16,97.846,-12.39],[31119,5.22,97.951,11.54],[31121,5.43,97.959,-8.16],[31125,4.34,97.964,-23.42],[31165,5.25,98.089,-37.7],[31216,4.47,98.226,7.33],[31278,5.09,98.408,-1.22],[31299,5.42,98.456,-36.23],[31407,4.35,98.744,-52.98],[31434,5.26,98.80,28.02],[31579,5.40,99.137,38.45],[31688,5.25,99.448,-32.34],[31700,4.42,99.473,-18.24],[31765,5.05,99.657,-48.22],[31789,5.34,99.705,39.9],[31827,4.82,99.82,-14.15],[31832,4.80,99.833,42.49],[31978,4.66,100.244,9.9],[32064,5.21,100.485,-9.17],[32104,5.20,100.601,17.65],[32173,5.04,100.771,44.52],[32249,4.49,100.997,13.23],[32292,5.23,101.119,-31.07],[32311,5.42,101.189,28.97],[32411,5.30,101.497,-14.8],[32438,4.86,101.559,59.44],[32439,5.44,101.559,79.56],[32480,5.24,101.685,43.58],[32489,5.34,101.706,57.17],[32492,5.28,101.713,-14.43],[32494,5.39,101.719,-51.27],[32533,4.77,101.833,8.04],[32537,5.27,101.839,-37.93],[32558,5.08,101.905,-9.0],[32562,5.22,101.915,48.79],[32578,4.48,101.965,2.41],[32677,5.39,102.241,-15.14],[32761,4.41,102.464,-53.62],[32765,5.14,102.478,-46.61],[32844,4.99,102.691,41.78],[32855,4.99,102.718,-34.37],[32864,5.14,102.738,67.57],[32912,5.41,102.862,-70.96],[32921,5.28,102.888,21.76],[33048,5.34,103.271,59.45],[33092,4.82,103.387,-20.22],[33104,5.11,103.426,68.89],[33184,5.44,103.603,-1.13],[33202,4.73,103.661,13.18],[33302,4.66,103.906,-20.14],[33316,5.29,103.946,-22.94],[33345,5.00,104.028,-14.04],[33357,4.94,104.067,-48.72],[33384,5.45,104.144,-79.42],[33478,5.45,104.391,-24.63],[33485,4.90,104.405,45.09],[33558,5.07,104.605,-34.11],[33682,5.18,104.961,-67.92],[33694,4.55,105.017,76.98],[33779,5.14,105.215,-51.4],[33878,5.22,105.485,-5.72],[33927,5.20,105.603,24.22],[33971,4.99,105.728,-4.24],[34033,5.14,105.909,10.95],[34059,4.92,105.973,-49.58],[34081,5.20,106.012,-42.34],[34105,5.14,106.076,-56.75],[34301,5.41,106.67,-11.29],[34440,5.47,107.092,15.93],[34495,4.83,107.213,-39.66],[34622,4.91,107.557,-4.24],[34624,5.46,107.581,-27.49],[34670,5.12,107.698,-48.93],[34724,5.44,107.848,-0.3],[34752,4.91,107.914,39.32],[34802,5.30,108.066,-40.5],[34834,4.49,108.14,-46.76],[34899,4.87,108.306,-45.18],[34909,5.07,108.343,16.16],[34912,5.46,108.348,51.43],[34922,4.42,108.385,-44.64],[34981,4.42,108.563,-26.35],[34987,5.36,108.584,3.11],[35020,4.75,108.659,-48.27],[35083,5.36,108.838,-30.69],[35146,5.20,108.979,59.64],[35180,5.46,109.061,-15.59],[35205,4.66,109.146,-27.88],[35210,4.83,109.153,-23.32],[35226,5.03,109.206,-36.59],[35363,4.65,109.577,-36.73],[35384,5.00,109.633,49.46],[35393,5.24,109.64,-39.21],[35406,5.11,109.659,-36.74],[35412,4.88,109.668,-24.56],[35415,4.37,109.677,-24.95],[35427,5.29,109.714,-26.59],[35589,5.38,110.162,-52.09],[35699,5.09,110.487,20.44],[35710,5.12,110.511,36.76],[35727,4.94,110.556,-19.02],[35795,5.40,110.753,-31.92],[35846,5.04,110.869,25.05],[35848,5.37,110.871,-27.83],[35855,5.41,110.883,-32.2],[35907,5.23,111.035,40.67],[35951,5.18,111.167,-16.2],[35957,5.35,111.183,-31.81],[35987,5.37,111.242,11.67],[36041,4.99,111.412,9.28],[36114,5.09,111.591,-51.02],[36238,5.20,111.935,21.45],[36265,5.22,112.009,6.94],[36284,4.33,112.041,8.93],[36363,5.41,112.274,-38.81],[36366,4.16,112.278,31.78],[36393,5.07,112.335,28.12],[36425,4.55,112.449,12.01],[36429,5.01,112.453,27.92],[36431,4.85,112.464,-23.02],[36439,5.35,112.483,49.67],[36514,4.65,112.678,-30.96],[36547,4.92,112.769,82.41],[36616,5.45,112.952,17.09],[36641,5.24,113.025,1.91],[36760,5.27,113.402,15.83],[36773,4.82,113.45,-14.52],[36778,5.42,113.463,-36.34],[36795,4.44,113.513,-22.3],[36817,5.06,113.578,-23.47],[36896,5.34,113.787,30.96],[36917,4.65,113.845,-28.37],[36942,4.93,113.916,-52.53],[37088,5.14,114.32,-4.11],[37096,4.53,114.342,-34.97],[37173,4.69,114.575,-25.36],[37265,4.89,114.791,34.58],[37297,4.84,114.864,-38.31],[37300,5.04,114.869,17.67],[37379,4.98,115.097,-15.26],[37391,5.05,115.127,87.02],[37450,5.41,115.316,-38.53],[37606,5.04,115.738,-45.17],[37609,4.93,115.752,58.71],[37629,4.23,115.828,28.88],[37648,4.63,115.885,-28.41],[37664,5.12,115.925,-40.93],[37701,5.31,116.017,50.43],[37704,5.30,116.029,25.78],[37853,5.36,116.396,-34.17],[37891,5.03,116.487,-14.56],[37901,5.49,116.509,-6.77],[37908,4.89,116.531,18.51],[37921,5.25,116.568,10.77],[37946,5.15,116.664,37.52],[38010,5.07,116.854,-38.51],[38016,5.14,116.876,33.42],[38020,5.22,116.881,-46.61],[38048,5.48,116.986,-12.19],[38070,4.40,117.022,-25.94],[38089,4.69,117.084,-47.08],[38164,4.10,117.31,-46.37],[38211,5.17,117.422,-17.23],[38373,5.12,117.925,1.77],[38382,5.16,117.943,-13.9],[38423,5.01,118.065,-34.71],[38455,4.49,118.161,-38.86],[38497,5.44,118.265,-36.36],[38500,4.63,118.265,-49.61],[38518,4.22,118.326,-48.1],[38538,4.97,118.374,26.77],[38593,5.48,118.546,-35.88],[38639,5.47,118.678,47.56],[38722,5.38,118.916,19.88],[38835,4.20,119.215,-22.88],[38846,5.36,119.241,-43.5],[38872,5.08,119.327,-44.11],[38901,4.76,119.417,-30.33],[38917,5.14,119.465,-45.58],[38957,4.47,119.56,-49.24],[38962,5.30,119.586,2.22],[39023,5.09,119.774,-23.31],[39061,5.22,119.868,-39.3],[39070,5.19,119.906,-60.59],[39079,4.93,119.934,-3.68],[39095,4.61,119.967,-18.4],[39117,5.37,120.049,73.92],[39138,4.81,120.083,-63.57],[39211,4.69,120.306,-1.39],[39311,4.39,120.566,2.33],[39424,4.94,120.88,27.79],[39487,5.25,121.067,-32.67],[39538,5.39,121.196,79.48],[39567,5.14,121.269,13.12],[39690,5.04,121.668,-45.27],[39734,5.33,121.825,-20.55],[39780,5.30,121.941,21.58],[39847,4.78,122.114,51.51],[39903,4.74,122.253,-61.3],[39906,4.40,122.257,-19.25],[39961,5.20,122.40,-44.12],[39970,5.23,122.43,-47.94],[40084,4.72,122.818,-12.93],[40091,4.44,122.84,-39.62],[40096,4.73,122.858,-42.99],[40107,5.36,122.888,-7.77],[40167,4.67,123.053,17.65],[40215,5.34,123.203,68.47],[40259,4.99,123.333,-15.79],[40274,4.78,123.373,-35.9],[40285,5.14,123.401,-46.99],[40321,5.09,123.493,-36.32],[40326,4.42,123.512,-40.35],[40429,5.16,123.816,-62.92],[40680,5.06,124.578,-65.61],[40706,4.44,124.639,-36.66],[40817,5.33,124.954,-71.51],[40888,4.34,125.161,-77.48],[40943,5.18,125.338,-36.48],[40945,4.83,125.346,-33.05],[41003,5.28,125.519,-73.4],[41039,4.79,125.632,-48.49],[41260,5.32,126.266,-24.05],[41296,5.18,126.381,-51.73],[41323,5.45,126.466,-42.15],[41325,5.13,126.478,7.56],[41483,5.08,126.902,-53.09],[41616,5.33,127.27,-47.93],[41639,5.03,127.365,-44.72],[41817,5.42,127.879,-19.58],[41822,5.33,127.899,18.09],[41909,5.33,128.177,20.44],[42080,5.47,128.651,65.15],[42088,5.01,128.682,-49.94],[42129,5.27,128.815,-58.22],[42134,4.84,128.832,-58.01],[42286,5.45,129.328,-62.85],[42312,4.11,129.411,-42.99],[42334,5.24,129.467,-26.25],[42425,5.19,129.772,-70.39],[42430,5.05,129.783,-22.66],[42459,5.45,129.849,-53.44],[42483,4.86,129.927,-29.56],[42504,5.18,129.99,-53.05],[42509,4.98,130.006,-12.48],[42527,4.59,130.053,64.33],[42540,5.20,130.08,-40.26],[42604,5.35,130.254,45.83],[42624,4.74,130.305,-47.32],[42637,5.46,130.331,-78.96],[42662,4.87,130.431,-15.94],[42679,5.20,130.487,-45.41],[42712,5.48,130.567,-48.1],[42715,5.49,130.579,-53.1],[42726,4.83,130.606,-53.11],[42834,5.15,130.918,-49.82],[42835,4.63,130.918,-7.23],[42884,4.05,131.10,-42.65],[43067,4.32,131.594,-13.55],[43082,5.43,131.627,-45.91],[43105,4.50,131.677,-56.77],[43142,5.28,131.812,-1.9],[43305,5.30,132.341,-3.44],[43325,5.47,132.413,-40.32],[43347,4.94,132.448,-45.31],[43352,5.19,132.465,-32.78],[43413,5.09,132.639,-46.53],[43414,5.34,132.645,-66.79],[43531,5.15,132.987,43.73],[43671,5.31,133.461,-47.52],[43721,5.40,133.561,30.58],[43825,4.87,133.882,-27.68],[43834,5.23,133.915,27.93],[43851,5.44,133.981,11.63],[43878,4.68,134.08,-52.72],[43908,5.43,134.171,-85.66],[43932,5.44,134.236,32.91],[43937,4.93,134.243,-59.23],[43970,5.22,134.312,15.32],[44093,5.17,134.718,-47.23],[44143,5.17,134.851,-59.08],[44154,5.23,134.886,32.42],[44191,4.45,135.023,-41.25],[44337,5.23,135.436,-52.19],[44390,4.74,135.636,67.63],[44405,5.45,135.684,24.45],[44599,4.47,136.287,-72.6],[44613,5.48,136.35,48.53],[44626,4.66,136.41,-70.54],[44659,4.99,136.493,5.09],[44798,5.23,136.937,10.67],[44818,5.42,137.00,29.65],[44824,4.62,137.012,-25.86],[44857,5.15,137.098,66.87],[44901,4.46,137.218,51.6],[44946,5.16,137.34,22.05],[44961,5.47,137.398,-8.79],[45038,4.80,137.598,67.13],[45075,4.67,137.729,63.51],[45085,4.99,137.768,-44.87],[45290,5.30,138.451,43.22],[45328,5.26,138.575,-55.57],[45333,5.18,138.586,61.42],[45344,5.24,138.602,-43.23],[45410,5.36,138.808,14.94],[45439,4.92,138.903,-38.57],[45448,4.63,138.938,-37.41],[45455,5.28,138.957,56.74],[45493,4.80,139.047,54.02],[45496,4.34,139.05,-57.54],[45505,5.12,139.096,-44.27],[45526,5.49,139.172,-8.74],[45527,5.24,139.174,-6.35],[45544,5.31,139.238,-39.4],[45571,5.38,139.322,-68.69],[45581,5.28,139.355,-74.89],[45631,5.26,139.524,-51.05],[45751,4.77,139.943,-11.97],[45811,4.80,140.121,-9.56],[45856,4.79,140.237,-62.4],[45902,4.71,140.373,-25.97],[46026,4.71,140.801,-28.83],[46107,5.34,141.038,-80.79],[46146,4.47,141.164,26.18],[46283,5.09,141.575,-53.38],[46358,5.46,141.776,-71.6],[46371,4.72,141.827,-22.34],[46404,5.38,141.945,-6.07],[46454,5.40,142.114,9.06],[46471,5.40,142.167,45.6],[46515,4.51,142.311,-35.95],[46578,5.49,142.477,-26.59],[46594,5.45,142.521,-51.52],[46735,5.39,142.885,35.1],[46741,5.46,142.901,-73.08],[46750,4.32,142.93,22.97],[46771,4.99,142.986,11.3],[46774,5.07,142.99,9.72],[46811,5.35,143.08,-40.65],[46880,5.02,143.302,-21.12],[46914,5.12,143.436,-49.01],[46928,5.07,143.472,-80.94],[46950,5.01,143.537,-51.26],[46974,4.08,143.611,-59.23],[46977,4.54,143.62,69.83],[47006,4.47,143.706,52.05],[47029,4.81,143.766,39.62],[47080,5.40,143.915,35.81],[47175,4.34,144.206,-49.36],[47193,4.28,144.272,81.33],[47204,5.44,144.303,-53.67],[47205,5.00,144.303,6.84],[47300,5.28,144.591,40.24],[47310,4.68,144.614,4.65],[47391,4.51,144.837,-61.33],[47452,5.07,145.077,-14.33],[47479,5.30,145.177,-57.98],[47522,4.76,145.321,-23.59],[47592,4.93,145.56,-23.92],[47654,5.15,145.738,72.25],[47723,5.36,145.933,14.02],[47758,4.78,146.05,-27.77],[47956,5.43,146.586,-76.78],[47965,5.09,146.632,57.13],[48113,5.08,147.147,46.02],[48224,5.09,147.488,-45.73],[48374,4.58,147.919,-46.55],[48390,5.29,147.971,24.4],[48437,5.07,148.127,-8.11],[48559,4.87,148.551,-25.93],[48615,4.94,148.718,-19.01],[48682,5.27,148.929,49.82],[48833,5.11,149.421,41.06],[48883,5.26,149.556,12.44],[49029,4.68,150.053,8.04],[49081,5.37,150.253,31.92],[49402,4.60,151.281,-13.06],[49485,5.06,151.547,-47.37],[49637,4.39,151.976,10.0],[49698,5.26,152.178,-65.82],[49712,4.85,152.234,-51.81],[49809,5.30,152.525,-12.82],[50070,5.27,153.345,-51.23],[50083,5.15,153.378,-66.37],[50303,5.49,154.06,29.31],[50333,5.42,154.17,13.73],[50414,5.25,154.408,-8.07],[50555,4.59,154.903,-55.03],[50564,4.78,154.934,19.47],[50676,4.50,155.228,-56.04],[50799,4.82,155.582,-41.65],[50847,4.97,155.742,-66.9],[50888,5.34,155.872,-38.01],[50933,4.94,156.033,65.57],[51056,4.72,156.478,33.8],[51192,4.65,156.852,-57.64],[51313,5.27,157.219,-64.17],[51362,5.19,157.37,-2.74],[51438,4.72,157.584,-71.99],[51459,4.82,157.657,55.98],[51495,4.94,157.759,-73.22],[51502,5.25,157.769,82.56],[51523,4.89,157.841,-53.72],[51585,5.43,158.049,14.14],[51635,5.02,158.237,-47.0],[51658,4.72,158.308,40.43],[51718,5.08,158.504,-23.75],[51775,5.07,158.70,6.95],[51808,4.86,158.773,75.71],[51814,5.16,158.79,57.08],[51849,4.45,158.897,-57.56],[51912,5.08,159.085,-59.56],[51979,4.87,159.307,-27.41],[52004,5.47,159.363,-58.73],[52009,4.89,159.389,-13.38],[52085,4.91,159.646,-16.88],[52098,4.68,159.68,31.98],[52102,4.69,159.687,-59.18],[52154,4.29,159.827,-55.6],[52353,5.12,160.486,65.72],[52370,4.76,160.559,-64.47],[52405,5.36,160.669,-59.22],[52425,5.01,160.767,69.08],[52457,5.08,160.854,23.19],[52469,5.18,160.887,46.2],[52502,4.80,161.029,-63.96],[52595,5.46,161.318,-80.47],[52633,4.45,161.446,-80.54],[52638,5.36,161.466,30.68],[52678,5.33,161.569,-64.51],[52689,5.49,161.605,14.19],[52701,5.23,161.623,-64.26],[52736,4.87,161.713,-64.38],[52737,5.44,161.717,-17.3],[52742,5.14,161.739,-56.76],[52911,5.32,162.314,10.55],[53154,5.26,163.129,-57.24],[53252,5.23,163.373,-20.14],[53261,5.12,163.394,54.59],[53273,5.45,163.432,-2.13],[53295,4.66,163.495,43.19],[53417,4.30,163.903,24.75],[53426,5.02,163.935,33.51],[53502,4.60,164.179,-37.14],[53721,5.03,164.867,40.43],[53773,4.37,165.039,-42.23],[53781,5.47,165.061,45.53],[53807,4.84,165.14,3.62],[53824,4.98,165.187,6.1],[53838,5.06,165.21,39.21],[53907,4.73,165.457,-2.48],[53954,4.42,165.582,20.18],[54173,5.43,166.226,-35.8],[54182,4.62,166.254,7.34],[54204,4.92,166.333,-27.29],[54301,4.62,166.635,-62.42],[54360,5.15,166.82,-42.64],[54461,5.11,167.142,-61.95],[54477,5.43,167.183,-28.08],[54746,5.37,168.138,-49.1],[54751,4.59,168.15,-60.32],[54767,5.22,168.188,-64.17],[54849,5.40,168.44,-0.07],[54951,4.56,168.801,23.1],[55016,5.31,168.966,13.31],[55084,4.45,169.165,-3.65],[55137,5.18,169.323,2.01],[55266,4.76,169.783,38.19],[55434,4.05,170.284,6.03],[55560,4.99,170.707,43.48],[55588,5.00,170.803,-36.16],[55597,5.09,170.839,-64.95],[55598,5.08,170.841,-18.78],[55642,4.00,170.981,10.53],[55650,5.39,171.01,1.41],[55756,5.21,171.373,-36.06],[55779,5.18,171.43,-63.97],[55831,5.22,171.648,-61.12],[55945,4.95,171.984,2.86],[56000,5.14,172.146,-42.67],[56034,5.30,172.267,39.34],[56127,4.77,172.579,-3.0],[56243,5.07,172.942,-59.44],[56250,5.12,172.953,-59.52],[56280,4.93,173.068,-29.26],[56290,5.46,173.086,61.08],[56332,5.13,173.226,-31.09],[56391,5.39,173.405,-40.59],[56573,5.26,173.982,-47.64],[56583,5.19,174.012,69.32],[56647,4.30,174.237,-0.82],[56656,5.14,174.252,-61.28],[56700,5.46,174.392,-47.75],[56754,5.15,174.53,-61.83],[56779,5.24,174.615,8.13],[56802,5.48,174.667,-13.2],[56862,5.01,174.873,-65.4],[56922,4.70,175.053,-34.74],[56975,5.26,175.196,21.35],[56986,4.93,175.223,-62.09],[56997,5.31,175.263,34.2],[57047,5.20,175.433,-32.5],[57111,5.32,175.618,66.74],[57175,5.00,175.88,-62.49],[57328,4.84,176.321,8.26],[57371,5.28,176.433,-45.69],[57439,4.11,176.628,-61.18],[57443,4.89,176.629,-40.5],[57477,5.27,176.732,55.63],[57512,5.42,176.83,-57.7],[57562,5.31,176.979,8.25],[57565,4.50,176.996,20.22],[57581,4.75,177.061,-66.81],[57613,5.10,177.188,-26.75],[57669,4.30,177.421,-63.79],[57696,4.98,177.486,-70.23],[57803,4.47,177.786,-45.17],[57851,4.89,177.963,-65.21],[58082,5.26,178.677,-25.71],[58379,5.44,179.563,-56.32],[58484,4.88,179.907,-78.22],[58510,5.36,179.987,3.66],[58587,5.28,180.213,-19.66],[58590,4.65,180.218,6.61],[58684,5.22,180.528,43.05],[58758,4.32,180.756,-63.31],[58803,5.15,180.915,-42.43],[58867,4.72,181.08,-63.17],[58884,5.34,181.162,-68.33],[58905,5.04,181.194,-76.52],[58948,4.12,181.302,8.73],[59072,4.14,181.72,-64.61],[59151,5.17,181.958,-75.37],[59173,4.46,182.022,-50.66],[59184,5.34,182.061,-48.69],[59394,5.45,182.766,-23.6],[59504,5.14,183.05,77.62],[59654,5.31,183.511,-45.72],[59819,5.09,184.001,14.9],[59847,4.93,184.086,23.95],[59856,4.99,184.126,33.06],[59929,4.06,184.393,-67.96],[60009,4.06,184.609,-64.0],[60044,5.47,184.708,75.16],[60059,5.01,184.749,-55.14],[60122,5.28,184.953,48.98],[60172,4.97,185.087,3.31],[60189,5.20,185.14,-22.22],[60202,4.72,185.179,17.79],[60221,5.14,185.232,-13.57],[60320,5.15,185.531,-67.52],[60351,4.78,185.626,25.85],[60379,5.38,185.706,-57.68],[60449,5.32,185.898,-35.41],[60485,4.76,186.006,51.56],[60514,5.17,186.077,26.1],[60646,5.01,186.462,39.02],[60697,4.92,186.60,27.27],[60710,4.82,186.632,-51.45],[60746,4.98,186.747,26.83],[60781,5.38,186.87,-58.99],[60855,5.45,187.094,-39.04],[60904,5.29,187.228,25.91],[60941,5.47,187.363,24.11],[60978,5.37,187.489,58.41],[60998,5.01,187.528,69.2],[61071,5.47,187.752,24.57],[61136,5.49,187.918,-59.42],[61309,5.42,188.412,33.25],[61318,5.48,188.445,-9.45],[61384,4.95,188.683,70.02],[61394,4.80,188.713,22.63],[61418,5.03,188.782,18.38],[61468,5.12,188.94,-41.02],[61621,5.41,189.426,-27.14],[61724,5.49,189.78,21.06],[61740,4.66,189.812,-8.0],[61789,4.63,189.969,-39.99],[61910,5.17,190.316,-13.01],[61960,4.88,190.471,10.24],[61966,4.91,190.486,-59.69],[62012,4.66,190.648,-48.81],[62027,5.27,190.709,-63.06],[62131,5.46,191.002,-28.32],[62223,5.42,191.283,45.44],[62267,5.22,191.404,7.67],[62268,4.69,191.409,-60.98],[62327,4.62,191.595,-56.49],[62356,5.12,191.662,16.58],[62423,5.43,191.893,66.79],[62572,5.38,192.307,83.41],[62683,4.90,192.672,-34.0],[62763,4.93,192.925,27.54],[62867,4.33,193.279,-48.94],[62886,4.89,193.324,21.24],[62896,4.25,193.359,-40.18],[62985,4.77,193.588,-9.54],[63003,4.03,193.648,-57.18],[63005,5.08,193.654,-57.17],[63007,4.62,193.663,-59.15],[63031,5.45,193.745,-85.12],[63066,5.46,193.831,-42.92],[63076,5.23,193.869,65.44],[63117,5.34,193.988,-56.84],[63210,5.17,194.268,-51.2],[63355,4.76,194.731,17.41],[63432,5.37,194.979,66.6],[63462,4.88,195.069,30.79],[63503,4.93,195.182,56.37],[63724,4.83,195.889,-49.53],[63901,5.20,196.435,35.8],[63945,4.71,196.57,-48.46],[64004,4.27,196.728,-49.91],[64022,4.80,196.795,27.62],[64078,5.15,196.974,-10.74],[64094,5.44,197.03,-65.31],[64238,4.38,197.487,-5.54],[64348,5.24,197.847,-43.37],[64408,4.85,198.013,-37.8],[64407,5.04,198.015,-16.2],[64425,4.58,198.073,-59.92],[64540,4.94,198.429,40.15],[64577,5.31,198.545,-19.93],[64583,4.90,198.563,-59.1],[64661,4.79,198.812,-67.89],[64725,5.21,198.995,-19.94],[64792,5.19,199.194,9.42],[64803,5.10,199.221,-31.51],[64820,4.86,199.304,-66.78],[64823,5.33,199.315,13.68],[64844,4.72,199.386,40.57],[64852,4.78,199.401,5.47],[64906,5.14,199.56,49.68],[64924,4.74,199.601,-18.31],[65112,5.47,200.158,-52.75],[65271,4.52,200.658,-60.99],[65301,5.36,200.755,-17.74],[65387,4.52,201.002,-64.54],[65468,5.04,201.28,-74.89],[65479,5.32,201.308,-64.49],[65535,5.11,201.532,-39.76],[65581,5.27,201.68,-12.71],[65639,4.76,201.863,-15.97],[65721,4.97,202.108,13.78],[65728,5.40,202.113,59.95],[65810,5.04,202.355,-51.17],[66006,4.68,202.991,-6.26],[66098,5.21,203.242,-10.16],[66200,4.92,203.533,3.66],[66234,4.68,203.614,49.02],[66257,4.91,203.699,37.18],[66458,4.82,204.365,36.29],[66634,5.46,204.877,52.92],[66738,4.63,205.184,54.68],[66803,5.03,205.403,-8.7],[66821,4.99,205.437,-54.56],[66849,5.38,205.505,-58.79],[66936,5.35,205.765,3.54],[67153,4.23,206.422,-33.04],[67234,4.64,206.664,-51.43],[67244,5.15,206.735,-36.25],[67275,4.50,206.816,17.46],[67288,5.41,206.856,-17.86],[67304,5.46,206.911,-50.32],[67457,4.19,207.361,-34.45],[67480,4.92,207.428,21.26],[67494,4.96,207.468,-18.13],[67627,4.58,207.858,64.72],[67665,4.76,207.948,34.44],[67669,4.32,207.957,-32.99],[67703,5.26,208.02,-52.81],[67786,4.75,208.302,-31.93],[67929,5.16,208.676,-1.5],[68103,5.02,209.142,27.49],[68191,4.71,209.412,-63.69],[68269,5.20,209.63,-24.97],[68523,4.34,210.431,-45.6],[68581,5.47,210.595,-27.43],[68862,4.36,211.512,-41.18],[68940,5.46,211.678,-9.31],[69038,5.13,211.982,43.85],[69068,5.26,212.072,49.46],[69112,4.80,212.212,77.55],[69191,4.74,212.478,-53.44],[69226,4.82,212.60,25.09],[69269,4.93,212.71,-16.3],[69373,5.18,213.017,69.43],[69389,4.99,213.066,2.41],[69415,5.07,213.192,-27.26],[69483,4.53,213.371,51.79],[69612,5.29,213.712,10.1],[69618,5.03,213.738,-57.09],[69713,4.75,214.041,51.37],[69732,4.18,214.096,46.09],[69879,4.80,214.499,35.51],[69896,4.89,214.558,-81.01],[69974,4.52,214.777,-13.37],[69989,5.41,214.818,13.0],[70012,5.14,214.885,-2.27],[70027,4.84,214.938,16.31],[70035,5.22,214.965,-61.27],[70069,4.30,215.081,-56.39],[70090,4.05,215.139,-37.89],[70104,4.78,215.177,-45.19],[70248,5.06,215.596,-80.11],[70264,4.76,215.654,-58.46],[70300,4.41,215.759,-39.51],[70306,4.78,215.774,-27.75],[70327,4.86,215.845,8.45],[70400,5.10,216.047,5.82],[70469,5.34,216.203,-24.81],[70497,4.04,216.299,51.85],[70574,4.56,216.534,-45.22],[70602,5.40,216.614,19.23],[70692,4.25,216.881,75.7],[70753,4.97,217.043,-29.49],[70755,4.81,217.051,-2.23],[70794,5.42,217.174,-6.9],[70931,5.38,217.587,-49.52],[71121,4.44,218.154,-50.46],[71284,4.47,218.67,29.75],[71500,5.39,219.334,-46.13],[71618,5.39,219.709,44.4],[71762,4.49,220.182,16.42],[71832,4.86,220.411,8.16],[71865,4.01,220.49,-37.79],[71995,4.80,220.856,26.53],[72010,4.06,220.914,-35.17],[72104,4.92,221.247,-35.19],[72125,4.60,221.31,16.96],[72131,5.36,221.322,-62.88],[72197,5.15,221.50,-25.44],[72290,5.22,221.755,-52.38],[72357,5.23,221.937,-26.09],[72489,5.32,222.329,-14.15],[72571,4.42,222.572,-27.96],[72582,5.47,222.624,37.27],[72603,5.15,222.672,-16.0],[72631,4.93,222.754,-2.3],[72659,4.54,222.847,19.1],[72664,5.48,222.86,59.29],[72683,4.32,222.91,-43.58],[72800,5.02,223.213,-37.8],[72929,5.27,223.584,-24.64],[73036,5.18,223.894,-60.11],[73049,5.32,223.936,-33.86],[73095,5.38,224.072,-52.81],[73129,5.08,224.183,-62.78],[73133,5.48,224.192,-11.41],[73165,4.47,224.296,-4.35],[73199,4.63,224.396,65.93],[73223,5.37,224.471,-76.66],[73473,4.91,225.243,-8.52],[73568,4.80,225.527,25.01],[73620,4.39,225.725,2.09],[73624,5.45,225.747,-32.64],[73695,4.83,225.947,47.65],[73745,4.52,226.111,26.95],[73776,5.16,226.201,-64.03],[73826,5.13,226.33,-41.07],[73909,5.24,226.57,54.56],[73945,5.19,226.657,-16.26],[73996,4.93,226.825,24.87],[74117,4.07,227.211,-45.28],[74305,5.45,227.817,-55.35],[74392,4.54,228.055,-19.79],[74449,4.83,228.207,-44.5],[74596,5.28,228.621,29.16],[74604,4.91,228.655,-31.52],[74605,5.15,228.66,67.35],[74649,5.32,228.797,4.94],[74707,5.15,229.017,-41.49],[74778,5.04,229.237,-60.96],[74793,5.02,229.275,71.82],[74837,4.85,229.412,-63.61],[74857,4.35,229.458,-30.15],[74911,4.27,229.633,-47.88],[74941,5.43,229.705,-60.5],[74975,5.04,229.828,1.77],[75119,5.35,230.258,0.72],[75178,5.38,230.452,32.93],[75206,4.99,230.534,-47.93],[75304,4.54,230.789,-36.86],[75312,4.99,230.801,30.29],[75379,4.92,231.05,-10.32],[75411,4.31,231.123,37.38],[75439,5.36,231.188,-39.71],[75501,4.60,231.334,-38.73],[75530,5.16,231.447,15.43],[75572,5.46,231.572,34.34],[75647,5.46,231.826,-36.77],[75761,5.15,232.159,1.84],[75828,5.26,232.351,-46.73],[75973,5.04,232.732,40.83],[76008,5.00,232.854,77.35],[76013,5.40,232.878,-73.39],[76041,4.98,232.946,40.9],[76219,4.61,233.545,-10.06],[76243,5.16,233.61,-9.18],[76259,5.13,233.656,-28.05],[76307,5.14,233.812,39.01],[76371,4.55,233.972,-44.96],[76397,5.44,234.05,-44.4],[76425,5.26,234.123,10.01],[76440,4.11,234.18,-66.32],[76534,5.25,234.457,40.35],[76618,5.43,234.706,-52.37],[76628,5.36,234.727,-19.3],[76669,4.64,234.844,36.64],[76705,4.66,234.942,-34.41],[76742,4.97,235.07,-23.82],[76829,4.64,235.297,-44.66],[76852,4.51,235.388,19.67],[76866,5.34,235.448,12.85],[76880,4.75,235.487,-19.68],[76939,5.23,235.66,-37.42],[76945,4.75,235.671,-34.71],[76957,5.48,235.711,52.36],[77060,5.41,236.018,-15.67],[77227,5.39,236.523,-1.8],[77257,4.42,236.611,7.35],[77277,5.19,236.667,62.6],[77578,5.21,237.573,2.2],[77635,4.63,237.745,-25.75],[77655,4.79,237.808,35.66],[77660,5.09,237.815,-3.09],[77661,4.74,237.816,20.98],[77811,5.04,238.334,-20.17],[77840,4.59,238.403,-25.33],[77858,5.38,238.475,-24.53],[77859,5.41,238.483,-23.98],[77902,5.45,238.644,20.31],[77907,5.35,238.658,43.14],[77982,5.11,238.873,-68.6],[78012,5.43,238.948,37.95],[78105,5.14,239.223,-33.97],[78180,4.96,239.448,54.75],[78207,4.95,239.547,-14.28],[78246,5.43,239.645,-24.83],[78323,4.99,239.876,-41.74],[78400,5.47,240.082,-16.53],[78459,5.39,240.261,33.3],[78481,5.10,240.31,17.82],[78554,4.82,240.574,22.8],[78592,4.72,240.70,46.04],[78650,4.96,240.836,-25.87],[78655,4.90,240.851,-38.6],[78662,4.63,240.884,-57.78],[78727,4.16,0.00,0.0],[78821,4.90,241.361,-19.8],[78893,5.44,241.582,67.81],[78914,4.73,241.623,-45.17],[78918,4.22,241.648,-36.8],[78990,4.31,241.851,-20.87],[79043,5.00,242.019,17.05],[79050,5.35,242.032,-26.33],[79101,4.23,242.192,44.93],[79119,4.73,242.243,36.49],[79195,5.39,242.46,-3.47],[79280,5.48,242.706,75.88],[79302,5.09,242.759,-29.42],[79374,4.00,242.999,-19.46],[79375,4.93,243.00,-10.06],[79387,5.43,243.03,-8.55],[79404,4.58,243.076,-27.93],[79488,5.46,243.314,5.02],[79540,5.24,243.462,-11.84],[79607,5.23,243.67,33.86],[79653,5.13,243.814,-47.37],[79672,5.49,243.905,-8.37],[79754,5.45,244.18,-53.81],[79790,4.97,244.254,-50.07],[79804,5.37,244.314,59.76],[79881,4.80,244.575,-28.61],[79963,5.44,244.824,-42.67],[80008,5.48,244.98,39.71],[80047,4.68,245.087,-78.7],[80057,5.27,245.112,-78.67],[80079,4.55,245.159,-24.17],[80161,5.26,245.453,69.11],[80179,4.82,245.518,1.03],[80181,4.86,245.524,30.89],[80197,5.20,245.589,33.8],[80208,5.32,245.617,-49.57],[80214,5.40,245.622,33.7],[80337,5.37,246.005,-39.19],[80343,4.48,246.026,-20.04],[80390,5.42,246.132,-37.57],[80399,5.40,246.166,-29.7],[80463,4.57,246.354,14.03],[80473,4.57,246.396,-23.45],[80569,4.22,246.756,-18.46],[80620,5.24,246.931,-7.6],[80628,4.62,246.951,-8.37],[80645,5.28,246.989,-64.06],[80650,4.94,246.996,68.77],[80686,4.90,247.117,-70.08],[80693,5.41,247.142,0.67],[80704,4.83,247.161,41.88],[80782,5.35,247.426,-46.24],[80815,4.79,247.552,-25.12],[80843,5.24,247.64,20.48],[80874,5.19,247.706,-61.63],[80894,4.29,247.785,-16.61],[80911,4.24,247.846,-34.7],[80945,5.31,247.924,-41.82],[80975,4.45,248.034,-21.47],[81008,4.84,248.151,11.49],[81122,4.86,248.521,-44.05],[81292,5.07,249.057,52.92],[81304,4.18,249.094,-35.26],[81305,5.46,249.094,-42.86],[81437,5.28,249.502,56.02],[81497,4.86,249.687,48.93],[81660,4.84,250.23,64.59],[81724,4.91,250.393,-17.74],[82020,4.84,251.324,56.78],[82073,5.15,251.458,8.58],[82129,5.10,251.667,-67.11],[82135,5.48,251.70,-39.38],[82216,5.22,251.943,5.25],[82321,4.82,252.309,45.98],[82369,4.64,252.458,-10.78],[82402,5.48,252.581,7.25],[82493,5.23,252.891,-41.23],[82504,5.03,252.939,24.66],[82587,5.34,253.242,31.7],[82673,4.39,253.502,10.17],[82676,5.46,253.508,-41.81],[82730,5.23,253.649,-6.15],[82764,5.39,253.73,20.96],[82802,5.35,253.842,18.43],[82860,4.88,254.007,65.13],[82960,5.48,254.297,-33.26],[83153,4.06,254.896,-53.16],[83262,4.82,255.265,-4.22],[83313,5.27,255.402,33.57],[83336,5.03,255.469,-32.14],[83430,4.97,255.783,14.09],[83431,5.27,255.786,-53.24],[83574,4.83,256.206,-34.12],[83608,4.91,256.334,54.47],[83613,4.89,256.345,12.74],[83838,5.41,257.009,35.94],[83947,5.07,257.389,40.78],[83962,5.43,257.45,-10.52],[84033,5.06,257.676,-44.56],[84177,5.32,258.116,10.59],[84405,4.33,258.837,-26.6],[84514,4.72,259.153,-0.45],[84573,4.80,259.332,33.1],[84626,5.14,259.503,-24.29],[84671,5.03,259.654,10.86],[84720,5.47,259.766,-46.64],[84821,5.36,260.041,25.54],[84833,5.01,260.079,18.06],[84862,5.38,260.165,32.47],[84887,5.13,260.226,24.5],[84893,4.39,260.252,-21.11],[84969,4.76,260.498,-67.77],[84979,5.39,260.524,-70.12],[85079,5.21,260.817,-47.47],[85084,5.30,260.84,-28.14],[85162,5.10,261.055,-44.16],[85312,5.19,261.50,-50.63],[85340,4.16,261.593,-24.18],[85355,4.34,261.629,4.14],[85365,4.53,261.658,-5.09],[85389,5.28,261.717,-45.84],[85423,4.28,261.839,-29.87],[85537,5.41,262.207,0.33],[85667,5.31,262.599,-1.06],[85805,5.07,262.991,68.14],[85819,4.89,263.044,55.18],[86036,5.23,263.748,61.87],[86092,4.56,263.915,-46.51],[86170,4.26,264.137,-38.64],[86182,5.35,264.157,48.59],[86201,4.77,264.238,68.76],[86284,4.58,264.461,-8.12],[86305,5.25,264.523,-54.5],[86486,4.76,265.099,-49.42],[86614,4.57,265.485,72.15],[86736,4.86,265.857,-21.68],[86796,5.12,266.036,-51.83],[87194,5.09,267.205,25.62],[87212,5.02,267.268,50.78],[87220,4.79,267.294,-31.7],[87234,5.02,267.363,76.96],[87294,4.78,267.546,-40.09],[87563,5.17,268.325,40.01],[87728,5.43,268.796,72.01],[87747,5.47,268.855,26.05],[87846,4.85,269.198,-44.34],[87847,5.44,269.199,-4.08],[87936,4.88,269.449,-41.72],[87998,4.41,269.626,30.19],[88060,5.00,269.772,-30.25],[88116,4.74,269.948,-23.82],[88128,4.67,270.014,16.75],[88149,4.79,270.066,4.37],[88175,4.62,270.121,-3.69],[88267,4.26,270.377,21.6],[88290,4.42,270.438,1.31],[88331,5.25,270.596,20.83],[88380,5.37,270.713,-24.28],[88404,4.77,270.77,-8.18],[88567,4.66,271.255,-29.58],[88601,4.03,271.364,2.5],[88657,4.96,271.508,22.22],[88726,4.92,271.708,-43.43],[88745,5.05,271.756,30.56],[88765,4.64,271.827,8.73],[88788,5.00,271.87,43.46],[88836,5.49,272.009,36.4],[88839,4.55,272.021,-28.46],[88886,4.37,272.19,20.81],[88899,5.10,272.22,20.05],[89042,5.47,272.609,-62.0],[89112,4.52,272.807,-45.95],[89153,4.96,272.931,-23.7],[89172,4.96,272.976,31.41],[89290,5.47,273.303,-41.34],[89348,4.99,273.474,64.4],[89369,5.49,273.566,-21.71],[89439,5.29,273.804,-20.73],[89507,5.45,273.973,-44.21],[89605,5.36,274.281,-56.02],[89678,4.66,274.513,-27.04],[89772,5.41,274.79,7.26],[89773,5.30,274.795,24.45],[89826,4.33,274.965,36.06],[89851,5.39,275.037,-15.83],[89861,4.92,275.075,21.96],[89908,4.22,275.189,71.34],[89918,4.85,275.217,3.38],[89935,5.12,275.254,28.87],[89981,5.02,275.386,49.12],[90023,5.41,275.536,23.29],[90037,5.09,275.577,-38.66],[90067,5.25,275.704,17.83],[90074,5.33,275.721,-36.67],[90133,5.47,275.902,-75.04],[90135,4.66,275.915,-8.93],[90156,4.98,275.978,58.8],[90191,5.11,276.057,39.51],[90200,5.24,276.076,-44.11],[90289,4.81,276.338,-20.54],[90344,4.82,276.496,65.56],[90414,5.44,276.725,-48.12],[90441,5.20,276.802,0.2],[90642,5.38,277.421,-1.99],[90763,5.37,277.77,-32.99],[90797,4.63,277.843,-62.28],[90806,5.12,277.86,-18.4],[90830,4.92,277.939,-45.91],[90853,5.07,278.008,-45.76],[90905,4.77,278.144,57.05],[90913,5.47,278.181,-14.87],[90923,5.47,278.208,30.55],[90982,4.62,278.376,-42.31],[91004,5.49,278.473,-24.03],[91013,5.38,278.486,52.35],[91014,5.28,278.491,-33.02],[91105,5.12,278.76,-10.98],[91217,5.38,279.116,9.12],[91235,5.41,279.156,33.47],[91237,5.43,279.163,6.67],[91494,5.42,279.896,-43.19],[91726,4.70,280.568,-9.05],[91755,5.03,280.658,55.54],[91845,4.88,280.88,-8.28],[91918,4.86,281.081,-35.64],[91919,4.67,281.085,39.67],[91926,4.59,281.095,39.61],[91975,5.02,281.208,2.06],[91989,5.40,281.238,-39.69],[92024,4.78,281.362,-64.87],[92043,4.19,281.416,20.55],[92056,5.25,281.445,74.09],[92088,4.83,281.519,26.66],[92111,5.37,281.586,-22.39],[92112,5.37,281.593,75.43],[92161,4.34,281.755,18.18],[92226,5.20,281.936,-40.41],[92308,5.46,282.21,-43.68],[92390,5.22,282.417,-20.32],[92405,5.22,282.47,32.55],[92512,4.63,282.80,59.39],[92614,5.43,283.068,21.43],[92646,5.18,283.165,-52.11],[92689,4.92,283.306,50.71],[92761,4.86,283.542,-22.74],[92782,4.82,283.599,71.3],[92818,4.57,283.687,22.65],[92824,5.29,283.696,-87.61],[92831,5.46,283.717,41.6],[92845,5.00,283.78,-22.67],[92862,4.08,283.834,43.95],[92951,4.98,284.061,4.2],[93017,5.20,284.257,32.9],[93026,4.83,284.265,-5.85],[93057,5.02,284.335,-20.66],[93124,5.33,284.561,17.36],[93148,4.85,284.616,-52.94],[93163,5.14,284.652,-60.2],[93203,5.27,284.774,13.62],[93256,5.26,284.94,26.23],[93279,4.94,285.003,32.15],[93299,5.39,285.057,50.53],[93408,5.00,285.36,46.93],[93429,4.02,285.42,-5.74],[93526,5.40,285.727,-3.7],[93667,5.49,286.104,-31.05],[93713,5.40,286.23,53.4],[93717,5.40,286.24,-4.03],[93815,5.17,286.583,-52.34],[93867,5.07,286.744,11.07],[93903,5.25,286.826,36.1],[93917,5.20,286.857,32.5],[94068,5.23,287.25,6.07],[94083,5.11,287.291,76.56],[94150,5.31,287.47,-68.42],[94302,5.13,287.919,56.86],[94385,5.35,288.17,-7.94],[94477,5.14,288.428,2.29],[94481,4.43,288.44,39.15],[94490,5.00,288.48,57.71],[94643,4.86,288.885,-25.26],[94648,4.45,288.888,73.36],[94703,4.76,289.054,21.39],[94712,5.38,289.091,-45.47],[94713,4.35,289.092,38.13],[94827,5.46,289.432,23.03],[94834,5.28,289.454,11.6],[94885,5.10,289.635,1.09],[95066,4.98,290.137,-5.42],[95073,5.46,290.149,-0.89],[95081,4.60,290.167,65.71],[95176,4.52,290.432,-15.96],[95260,5.22,290.712,26.26],[95261,5.03,290.713,-54.42],[95372,4.99,291.032,29.62],[95447,5.17,291.243,11.94],[95477,5.02,291.319,-24.51],[95498,5.14,291.369,19.8],[95503,5.45,291.374,-23.96],[95556,5.17,291.538,36.32],[95585,4.64,291.63,0.34],[95865,5.46,292.467,-26.99],[95937,5.03,292.666,-2.79],[95951,5.12,292.689,27.97],[96052,4.74,292.943,34.45],[96100,4.67,293.09,69.66],[96229,4.45,293.522,7.38],[96275,5.00,293.645,19.77],[96288,5.34,293.672,42.41],[96302,5.39,293.712,29.46],[96327,5.12,293.78,-10.56],[96341,4.88,293.804,-48.1],[96441,4.49,294.111,50.22],[96459,5.17,294.158,44.69],[96465,4.59,294.177,-24.88],[96468,4.36,294.18,-1.29],[96483,4.93,294.223,-7.03],[96536,5.46,294.393,-14.3],[96556,5.45,294.447,-4.65],[96665,5.18,294.799,5.4],[96683,4.68,294.844,30.15],[96693,5.41,294.86,42.82],[96808,5.30,295.181,-16.29],[96825,5.06,295.209,45.52],[96950,5.06,295.63,-16.12],[96957,5.28,295.642,11.83],[97063,5.49,295.89,-15.47],[97118,4.89,296.069,37.35],[97290,4.87,296.591,-19.76],[97295,5.00,296.607,33.73],[97421,5.33,297.005,-56.36],[97496,5.01,297.244,19.14],[97534,5.39,297.355,-72.5],[97630,5.18,297.642,38.72],[97635,5.03,297.657,52.99],[97646,5.41,297.687,-59.19],[97650,5.38,297.695,-10.76],[97675,5.12,297.757,10.42],[97679,4.90,297.767,22.61],[97749,5.32,297.961,-39.87],[97870,5.14,298.322,57.52],[97886,4.57,298.365,24.08],[97938,4.71,298.562,8.46],[98055,4.91,298.907,52.44],[98066,4.70,298.96,-26.3],[98068,4.95,298.966,38.49],[98073,4.98,298.981,58.85],[98103,5.28,299.059,11.42],[98162,4.54,299.237,-27.17],[98174,5.24,299.276,-58.9],[98194,5.46,299.308,40.37],[98258,5.01,299.488,-15.49],[98353,4.84,299.738,-26.2],[98421,5.30,299.964,-34.7],[98425,5.15,299.98,37.04],[98438,5.33,300.014,17.52],[98571,5.06,300.34,50.1],[98583,5.22,300.369,64.82],[98608,4.95,300.436,-59.38],[98624,5.32,300.469,-66.94],[98636,5.23,300.506,24.94],[98702,4.51,300.704,67.87],[98761,4.77,300.889,-37.94],[98842,4.99,301.082,-32.06],[98962,5.40,301.387,62.0],[99031,5.38,301.591,35.97],[99080,5.08,301.723,23.61],[99120,4.93,301.846,-52.88],[99255,4.38,302.222,77.71],[99303,4.93,302.357,36.84],[99461,5.32,302.80,-36.1],[99631,5.44,303.308,-1.01],[99639,4.80,303.325,46.82],[99655,4.28,303.349,56.57],[99738,5.19,303.561,28.69],[99742,4.94,303.569,15.2],[99770,4.93,303.633,36.81],[99824,4.79,303.816,25.59],[99853,5.18,303.876,23.51],[99874,4.50,303.942,27.81],[99951,5.30,304.196,24.67],[99968,5.27,304.23,40.37],[100027,4.30,304.412,-12.51],[100044,4.77,304.447,38.03],[100122,5.14,304.663,34.98],[100195,5.28,304.848,-19.12],[100310,4.77,305.166,-12.76],[100541,5.30,305.795,5.34],[100587,4.43,305.965,32.19],[100881,5.08,306.83,-18.21],[100965,5.38,307.061,81.42],[101027,4.77,307.215,-17.81],[101076,4.01,307.349,30.37],[101093,4.21,307.395,62.99],[101101,4.91,307.413,-2.89],[101138,4.94,307.515,48.95],[101243,5.44,307.828,49.22],[101260,5.18,307.877,74.95],[101474,4.61,308.476,35.25],[101477,5.12,308.479,-44.52],[101483,5.39,308.488,13.03],[101589,4.64,308.827,14.67],[101612,4.75,308.895,-60.58],[101692,4.91,309.182,-2.55],[101773,4.86,309.397,-61.53],[101800,5.42,309.455,11.38],[101847,4.31,309.585,-1.11],[101867,4.81,309.631,21.2],[101868,5.06,309.633,24.12],[101916,5.07,309.782,10.09],[101923,5.24,309.818,-14.95],[101936,5.15,309.854,0.49],[101983,5.11,310.011,-60.55],[101984,5.15,310.012,-18.14],[102014,5.47,310.083,-33.43],[102157,5.14,310.488,-66.76],[102177,5.41,310.553,50.34],[102333,4.51,311.01,-51.92],[102388,4.92,311.219,25.27],[102431,4.52,311.338,57.58],[102453,4.22,311.416,30.72],[102497,5.48,311.584,-39.2],[102531,5.15,311.662,16.12],[102571,4.93,311.795,34.37],[102589,4.53,311.852,36.49],[102599,5.36,311.889,80.55],[102624,4.43,311.934,-5.03],[102693,5.11,312.121,-43.99],[102724,4.81,312.235,46.11],[102773,5.41,312.326,-68.78],[102790,4.90,312.371,-46.23],[102843,5.06,312.521,44.06],[102950,5.06,312.875,-51.61],[103004,4.56,313.032,27.1],[103045,4.73,313.163,-8.98],[103089,4.80,313.311,44.39],[103094,5.48,313.327,45.18],[103127,5.34,313.417,-39.81],[103145,5.47,313.475,33.44],[103200,5.03,313.64,28.06],[103294,5.19,313.903,13.72],[103401,5.49,314.225,-9.7],[103511,5.30,314.568,22.33],[103569,5.30,314.769,4.29],[103571,5.24,314.771,4.29],[103632,4.74,314.956,47.52],[103732,5.38,315.296,46.16],[103882,5.32,315.741,-38.63],[104019,4.82,316.101,-19.85],[104043,5.13,316.179,-77.02],[104085,5.17,316.309,-54.73],[104174,5.20,316.603,-32.34],[104194,4.56,316.65,47.65],[104214,5.20,316.725,38.75],[104234,4.49,316.782,-25.01],[104365,5.30,317.14,-21.19],[104382,5.45,317.195,-88.96],[104459,4.50,317.399,-11.37],[104738,5.25,318.263,-39.42],[104750,5.41,318.322,-27.62],[104755,5.06,318.335,-70.13],[104963,5.17,318.908,-20.65],[104974,5.31,318.937,-15.17],[105102,4.22,319.354,39.39],[105138,4.41,319.479,34.9],[105143,5.40,319.489,-17.99],[105186,5.04,319.613,43.95],[105268,5.19,319.843,64.87],[105382,4.80,320.19,-40.81],[105502,4.08,320.522,19.8],[105665,5.38,321.04,-20.85],[105668,5.48,321.048,-12.88],[105767,5.48,321.321,-3.56],[105898,5.29,321.715,48.84],[105942,5.30,321.839,37.12],[105966,5.39,321.917,27.61],[105972,5.42,321.942,66.81],[106039,4.50,322.181,-21.81],[106044,5.47,322.187,-69.51],[106093,5.22,322.362,46.54],[106140,4.52,322.487,23.64],[106327,5.29,323.024,-41.18],[106551,4.87,323.694,38.53],[106711,5.04,324.237,40.41],[106723,4.51,324.27,-19.47],[106786,4.68,324.438,-7.85],[106787,5.46,324.439,19.32],[106801,4.76,324.48,62.08],[106944,5.10,324.889,2.24],[106999,5.09,325.046,43.27],[107095,5.16,325.387,-14.05],[107119,4.55,325.48,71.31],[107128,5.24,325.503,-23.26],[107136,4.69,325.524,51.19],[107151,5.30,325.564,5.68],[107188,4.72,325.665,-18.87],[107230,5.18,325.767,72.32],[107259,4.23,325.877,58.78],[107348,4.34,326.128,17.35],[107380,4.35,326.237,-33.03],[107382,5.10,326.251,-9.08],[107418,4.25,326.362,61.12],[107472,5.29,326.518,22.95],[107533,4.23,326.698,49.31],[107763,5.07,327.461,30.17],[107788,5.34,327.536,17.29],[107843,5.27,327.727,-82.72],[108022,5.09,328.266,25.93],[108036,5.08,328.324,-13.55],[108294,5.45,329.095,-37.25],[108317,5.11,329.163,63.63],[108431,4.40,329.479,-54.99],[108535,5.04,329.812,73.18],[108870,4.69,330.84,-56.79],[108874,4.74,330.829,-2.16],[108917,4.26,330.948,64.63],[108924,5.26,330.971,63.12],[108991,5.29,331.198,-0.91],[109005,5.27,331.252,62.79],[109017,5.07,331.287,62.28],[109068,4.86,331.42,5.06],[109102,5.09,331.508,45.01],[109285,4.50,332.096,-32.99],[109289,4.99,332.108,-34.04],[109400,4.79,332.452,72.34],[109404,5.37,332.482,-34.01],[109410,4.28,332.497,33.18],[109472,5.43,332.656,-11.56],[109521,5.38,332.791,50.82],[109556,5.05,332.877,59.41],[109572,5.24,332.953,56.84],[109592,5.37,333.008,60.76],[109654,5.34,333.199,34.6],[109693,5.27,333.294,86.11],[109754,4.50,333.47,39.71],[109786,5.33,333.575,-21.07],[109789,5.45,333.578,-27.77],[109857,4.18,333.759,57.04],[109908,4.79,333.904,-41.35],[109973,5.11,334.111,-41.63],[110000,5.34,334.20,-12.83],[110078,5.49,334.461,-77.51],[110109,5.36,334.565,-53.63],[110256,5.09,335.007,-80.44],[110273,5.35,335.05,-7.82],[110298,5.37,335.115,5.79],[110351,4.55,335.256,46.54],[110371,4.78,335.331,28.33],[110386,4.82,335.379,12.21],[110391,5.12,335.398,-21.6],[110618,5.28,336.154,-72.26],[110649,5.31,336.235,-57.8],[110672,4.80,336.319,1.38],[110725,5.47,336.503,70.77],[110838,4.51,336.833,-64.97],[110882,4.78,336.965,4.7],[110936,5.47,337.163,-39.13],[110991,4.07,337.293,58.42],[111043,4.12,337.439,-43.75],[111056,5.45,337.471,78.82],[111310,4.91,338.25,-61.98],[111449,5.21,338.673,-20.71],[111532,5.08,338.942,73.64],[111674,4.64,339.343,51.55],[111710,5.04,339.439,-4.23],[111795,5.11,339.658,56.8],[111797,5.19,339.663,63.58],[111841,4.89,339.815,39.05],[111944,4.50,340.129,44.28],[112031,5.25,340.369,40.23],[112051,4.80,340.439,29.31],[112203,4.84,340.875,-41.41],[112211,4.68,340.897,-18.83],[112242,5.11,341.023,41.82],[112374,4.84,341.408,-53.5],[112519,4.77,341.871,83.15],[112529,5.24,341.888,-19.61],[112731,5.43,342.443,55.9],[112781,5.32,342.595,-80.12],[112832,5.43,342.759,-39.16],[112917,4.95,343.008,43.31],[112935,5.16,343.10,9.84],[112948,4.46,343.131,-32.88],[113116,4.70,343.604,84.35],[113186,4.91,343.807,8.82],[113288,4.99,344.108,49.73],[113327,5.34,344.269,48.68],[113357,5.45,344.367,20.77],[113521,5.43,344.864,0.96],[113561,5.10,345.021,56.95],[113788,5.09,345.652,42.76],[113860,5.12,345.874,-34.75],[113864,5.25,345.887,67.21],[113889,4.48,345.969,3.82],[113919,4.64,346.046,50.05],[113957,5.37,346.165,-53.96],[113996,5.44,346.291,-7.69],[114104,4.84,346.653,59.42],[114119,4.48,346.67,-23.74],[114144,4.54,346.751,9.41],[114155,4.76,346.778,25.47],[114200,5.30,346.914,46.39],[114222,4.41,346.974,75.39],[114273,5.42,347.171,2.13],[114347,5.05,347.381,8.68],[114375,4.71,347.479,-22.46],[114389,5.39,347.506,9.82],[114520,5.15,347.934,8.72],[114570,4.53,348.138,49.41],[114724,4.22,348.581,-6.05],[114939,4.93,349.212,-7.73],[115022,4.82,349.436,49.02],[115033,4.41,349.476,-9.18],[115088,4.75,349.656,68.11],[115115,4.99,349.74,-9.61],[115125,5.19,0.00,0.0],[115126,5.20,349.778,-13.46],[115152,5.44,349.874,48.63],[115227,5.05,350.086,5.38],[115250,4.58,350.159,23.74],[115355,5.35,350.479,31.81],[115404,5.19,350.663,-15.04],[115444,5.09,350.769,12.31],[115590,4.96,351.209,62.28],[115623,4.42,351.345,23.4],[115669,4.38,351.512,-20.64],[115919,4.54,352.289,12.76],[115990,4.89,352.508,58.55],[116076,5.22,352.823,39.24],[116247,4.70,353.319,-20.91],[116264,5.33,353.367,22.5],[116310,4.97,353.488,31.33],[116389,4.69,353.769,-42.62],[116602,4.74,354.462,-45.49],[116611,5.49,354.487,18.4],[116631,4.29,354.534,43.27],[116709,5.35,354.785,50.47],[116758,4.97,354.946,-14.22],[116805,4.15,355.102,44.33],[116820,5.30,355.159,-32.07],[116889,5.36,355.394,-18.03],[116901,4.82,355.441,-17.82],[116957,5.27,355.616,-15.45],[116971,4.49,355.681,-14.54],[117020,5.09,355.843,10.33],[117073,4.93,355.998,29.36],[117089,5.24,356.05,-18.28],[117245,4.95,356.598,3.49],[117218,5.28,356.504,-18.68],[117221,4.97,356.509,46.42],[117301,4.88,356.764,58.65],[117315,5.18,356.817,-50.23],[117371,5.05,356.978,67.81],[117375,5.49,356.986,-2.76],[117447,5.43,357.209,62.21],[117452,4.59,357.231,-28.13],[117629,5.17,357.839,-18.91],[117689,5.10,358.027,-82.02],[117718,5.06,358.122,19.12],[117730,5.30,358.155,10.95],[117863,4.51,358.596,57.5],[118121,5.00,359.396,-64.3],[118131,4.63,359.44,25.14],[118209,4.88,359.668,-3.56],[118234,5.13,359.732,-52.75],[118243,4.88,359.752,55.75],[118322,4.49,359.979,-65.58]] }
    \ No newline at end of file
    diff --git a/html/allsky/virtualsky/stuquery.js b/html/allsky/virtualsky/stuquery.js
    new file mode 100644
    index 000000000..4acdb2ee0
    --- /dev/null
    +++ b/html/allsky/virtualsky/stuquery.js
    @@ -0,0 +1,521 @@
    +/*!
    + * stuQuery
    + */
    +(function(root){
    +
    +	var eventcache = {};
    +
    +	function stuQuery(els){
    +		// Make our own fake, tiny, version of jQuery simulating the parts we need
    +		this.stuquery = "1.0.26";
    +
    +		this.getBy = function(e,s){
    +			var i,m,k;
    +			i = -1;
    +			var result = [];
    +			if(s.indexOf(':eq') > 0){
    +				m = s.replace(/(.*)\:eq\(([0-9]+)\)/,'$1 $2').split(" ");
    +				s = m[0];
    +				i = parseInt(m[1]);
    +			}
    +			if(s[0] == '.') els = e.getElementsByClassName(s.substr(1));
    +			else if(s[0] == '#') els = e.getElementById(s.substr(1));
    +			else els = e.getElementsByTagName(s);
    +			if(!els) els = [];
    +			// If it is a select field we don't want to select the options within it
    +			if(els.nodeName && els.nodeName=="SELECT") result.push(els);
    +			else{
    +				if(typeof els.length!=="number") els = [els];
    +				for(k = 0; k < els.length; k++){ result.push(els[k]); }
    +				if(i >= 0 && result.length > 0){
    +					if(i < result.length) result = [result[i]];
    +					else result = [];
    +				}
    +			}
    +			return result;
    +		};
    +		this.matchSelector = function(e,s){
    +			// Does this one element match the s
    +			if(s[0] == '.'){
    +				s = s.substr(1);
    +				for(var i = 0; i < e.classList.length; i++) if(e.classList[i] == s) return true;
    +			}else if(s[0] == '#'){
    +				if(e.id == s.substr(1)) return true;
    +			}else{
    +				if(e.tagName == s.toUpperCase()) return true;
    +			}
    +			return false;
    +		};
    +		if(typeof els==="string") this.e = this.querySelector(document,els);
    +		else if(typeof els==="object") this.e = (typeof els.length=="number") ? els : [els];
    +		for(var it in this.e){
    +			if(this.e[it]) this[it] = this.e[it];
    +		}
    +		this.length = (this.e ? this.e.length : 0);
    +
    +		return this;
    +	}
    +	stuQuery.prototype.querySelector = function(els,selector){
    +		var result = [];
    +		var a,els2,i,j,k,tmp;
    +		if(selector.indexOf(':eq') >= 0){
    +			a = selector.split(' ');
    +			for(i = 0; i < a.length; i++){
    +				if(i==0){
    +					tmp = this.getBy(els,a[i]);
    +				}else{
    +					els2 = [];
    +					for(j = 0; j < tmp.length; j++) els2 = els2.concat(this.getBy(tmp[j],a[i]));
    +					tmp = els2.splice(0);
    +				}
    +			}
    +		}else tmp = els.querySelectorAll(selector);					// We can use the built-in selector
    +		for(k = 0; k < tmp.length; k++){ result.push(tmp[k]); }
    +		return result;
    +	};
    +	stuQuery.prototype.ready = function(f){
    +		if(/in/.test(document.readyState)) setTimeout('S(document).ready('+f+')',9);
    +		else f();
    +	};
    +	stuQuery.prototype.html = function(html){
    +		// Return HTML or set the HTML
    +		if(typeof html==="number") html = ''+html;
    +		if(typeof html!=="string" && this.length == 1) return this[0].innerHTML;
    +		if(typeof html==="string") for(var i = 0; i < this.length; i++) this[i].innerHTML = html;
    +		return this;
    +	};
    +	stuQuery.prototype.append = function(html){
    +		if(!html && this.length == 1) return this[0].innerHTML;
    +		if(html){
    +			for(var i = 0; i < this.length; i++){
    +				var d = document.createElement('template');
    +				d.innerHTML = html;
    +				var c = (typeof d.content==="undefined" ? d : d.content);
    +				if(c.childNodes.length > 0) while(c.childNodes.length > 0) this[i].appendChild(c.childNodes[0]);
    +				else this[i].append(html);
    +			}
    +		}
    +		return this;	
    +	};
    +	stuQuery.prototype.prepend = function(t){
    +		var i,j,d,e;
    +		if(!t && this.length==1) return this[0].innerHTML;
    +		for(i = 0 ; i < this.length ; i++){
    +			d = document.createElement('div');
    +			d.innerHTML = t;
    +			e = d.childNodes;
    +			for(j = e.length-1; j >= 0; j--) this[i].insertBefore(e[j], this[i].firstChild);
    +		}
    +		return this;
    +	};
    +	stuQuery.prototype.before=function(t){
    +		var i,d,e,j;
    +		for(i = 0 ; i < this.length ; i++){
    +			d = document.createElement('div');
    +			d.innerHTML = t;
    +			e = d.childNodes;
    +			for(j = 0; j < e.length; j++) this[i].parentNode.insertBefore(e[j], this[i]);
    +		}
    +		return this;
    +	};
    +	stuQuery.prototype.after = function(t){
    +		for(var i = 0 ; i < this.length ; i++) this[i].insertAdjacentHTML('afterend', t);
    +		return this;
    +	};
    +	function NodeMatch(a,el){
    +		if(a && a.length > 0){
    +			for(var i = 0; i < a.length; i++){
    +				if(a[i].node == el) return {'success':true,'match':i};
    +			}
    +		}
    +		return {'success':false};
    +	}
    +	function storeEvents(e,event,fn,fn2,data){
    +		if(!eventcache[event]) eventcache[event] = [];
    +		eventcache[event].push({'node':e,'fn':fn,'fn2':fn2,'data':data});
    +	}
    +	function getEvent(e){
    +		if(eventcache[e.type]){
    +			var m = NodeMatch(eventcache[e.type],e.currentTarget);
    +			if(m.success){
    +				if(m.match.data) e.data = eventcache[e.type][m.match].data;
    +				return {'fn':eventcache[e.type][m.match].fn,'data':e};
    +			}
    +		}
    +		return function(){ return {'fn':''}; };
    +	}
    +	stuQuery.prototype.off = function(event){
    +		// Try to remove an event with attached data and supplied function, fn.
    +
    +		// If the remove function doesn't exist, we make it
    +		if(typeof Element.prototype.removeEventListener !== "function"){
    +			Element.prototype.removeEventListener = function (sEventType, fListener /*, useCapture (will be ignored!) */) {
    +				if (!oListeners.hasOwnProperty(sEventType)) { return; }
    +				var oEvtListeners = oListeners[sEventType];
    +				for (var nElIdx = -1, iElId = 0; iElId < oEvtListeners.aEls.length; iElId++) {
    +					if (oEvtListeners.aEls[iElId] === this) { nElIdx = iElId; break; }
    +				}
    +				if (nElIdx === -1) { return; }
    +				for (var iLstId = 0, aElListeners = oEvtListeners.aEvts[nElIdx]; iLstId < aElListeners.length; iLstId++) {
    +					if (aElListeners[iLstId] === fListener) { aElListeners.splice(iLstId, 1); }
    +				}
    +			};
    +		}
    +		for(var i = 0; i < this.length; i++){
    +			var m = NodeMatch(eventcache[event],this.e[i]);
    +			if(m.success){
    +				this[i].removeEventListener(event,eventcache[event][m.match].fn2,false);
    +				eventcache[event].splice(m.match,1);
    +			}
    +		}
    +		return this;
    +	};
    +	stuQuery.prototype.on = function(event,data,fn){
    +		// Add events
    +		var events = (event || window.event).split(/ /);
    +		if(typeof data==="function" && !fn){
    +			fn = data;
    +			data = "";
    +		}
    +		if(typeof fn !== "function") return this;
    +
    +		if(this.length > 0){
    +			var _obj = this;
    +			var f;
    +			for(var ev = 0; ev < events.length; ev++){
    +				event = events[ev];
    +				f = function(b){
    +					var e = getEvent({'currentTarget':this,'type':event,'data':data,'originalEvent':b,'preventDefault':function(){ if(b.preventDefault) b.preventDefault(); },'stopPropagation':function(){
    +						if(b.stopImmediatePropagation) b.stopImmediatePropagation();
    +						if(b.stopPropagation) b.stopPropagation();
    +						if(b.cancelBubble!=null) b.cancelBubble = true;
    +					}});
    +					if(typeof e.fn === "function") return e.fn.call(_obj,e.data);
    +				};
    +				for(var i = 0; i < this.length; i++){
    +					storeEvents(this[i],event,fn,f,data);
    +					if(this[i].addEventListener) this[i].addEventListener(event, f, false); 
    +					else if(this[i].attachEvent) this[i].attachEvent(event, f);
    +				}
    +			}
    +		}
    +		return this;
    +	};
    +	stuQuery.prototype.trigger = function(e,orig){
    +		var event; // The custom event that will be created
    +		var events = e.split(/ /);
    +
    +		for(var ev = 0; ev < events.length; ev++){
    +			if(document.createEvent) {
    +				event = document.createEvent("HTMLEvents");
    +				event.initEvent((orig||events[ev]), true, true);
    +			}else{
    +				event = document.createEventObject();
    +				event.eventType = (orig||events[ev]);
    +			}
    +			event.eventName = e;
    +
    +			for(var i = 0 ;  i < this.length ; i++){
    +				if(document.createEvent) this[i].dispatchEvent(event);
    +				else this[i].fireEvent("on" + event.eventType, event);
    +			}
    +		}
    +
    +		return this;
    +	};
    +	stuQuery.prototype.focus = function(){
    +		// If there is only one element, we trigger the focus event
    +		if(this.length == 1) this[0].focus();
    +		return this;
    +	};
    +	stuQuery.prototype.blur = function(){
    +		// If there is only one element, we trigger the blur event
    +		if(this.length == 1) this[0].blur();
    +		return this;
    +	};
    +	stuQuery.prototype.remove = function(){
    +		// Remove DOM elements
    +		if(this.length < 1) return this;
    +		for(var i = this.length-1; i >= 0; i--){
    +			if(!this[i]) return;
    +			if(typeof this[i].remove==="function") this[i].remove();
    +			else if(typeof this[i].parentElement.removeChild==="function") this[i].parentElement.removeChild(this[i]);
    +		}
    +		return this;
    +	};
    +	stuQuery.prototype.hasClass = function(cls){
    +		// Check if a DOM element has the specified class
    +		var result = true;
    +		for(var i = 0; i < this.length; i++){
    +			if(!this[i].className.match(new RegExp("(\\s|^)" + cls + "(\\s|$)"))) result = false;
    +		}
    +		return result;
    +	};
    +	stuQuery.prototype.toggleClass = function(cls){
    +		// Toggle a class on a DOM element
    +		for(var i = 0; i < this.length; i++){
    +			if(this[i].className.match(new RegExp("(\\s|^)" + cls + "(\\s|$)"))) this[i].className = this[i].className.replace(new RegExp("(\\s|^)" + cls + "(\\s|$)", "g")," ").replace(/ $/,'');
    +			else this[i].className = (this[i].className+' '+cls).replace(/^ /,'');
    +		}
    +		return this;
    +	};
    +	stuQuery.prototype.addClass = function(cls){
    +		// Add a class on a DOM element
    +		for(var i = 0; i < this.length; i++){
    +			if(!this[i].className.match(new RegExp("(\\s|^)" + cls + "(\\s|$)"))) this[i].className = (this[i].className+' '+cls).replace(/^ /,'');
    +		}
    +		return this;
    +	};
    +	stuQuery.prototype.removeClass = function(cls){
    +		// Remove a class on a DOM element
    +		for(var i = 0; i < this.length; i++){
    +			while(this[i].className.match(new RegExp("(\\s|^)" + cls + "(\\s|$)"))) this[i].className = this[i].className.replace(new RegExp("(\\s|^)" + cls + "(\\s|$)", "g")," ").replace(/ $/,'').replace(/^ /,'');
    +		}
    +		return this;
    +	};
    +	stuQuery.prototype.css = function(css){
    +		var styles,i,key;
    +		if(this.length==1 && typeof css==="string"){
    +			styles = window.getComputedStyle(this[0]);
    +			return styles[css];
    +		}
    +		for(i = 0; i < this.length; i++){
    +			// Read the currently set style
    +			styles = {};
    +			var style = this[i].getAttribute('style');
    +			if(style){
    +				var bits = this[i].getAttribute('style').split(";");
    +				for(var b = 0; b < bits.length; b++){
    +					var pairs = bits[b].split(":");
    +					if(pairs.length==2) styles[pairs[0]] = pairs[1];
    +				}
    +			}
    +			if(typeof css==="object"){
    +				// Add the user-provided style to what was there
    +				for(key in css){
    +					if(typeof css[key]!=="undefined") styles[key] = css[key];
    +				}
    +				// Build the CSS string
    +				var newstyle = '';
    +				for(key in styles){
    +					if(typeof styles[key]!=="undefined"){
    +						if(newstyle) newstyle += ';';
    +						if(styles[key]) newstyle += key+':'+styles[key];
    +					}
    +				}
    +				// Update style
    +				this[i].setAttribute('style',newstyle);
    +			}
    +		}
    +		return this;
    +	};
    +	stuQuery.prototype.parent = function(){
    +		var tmp = [];
    +		for(var i = 0; i < this.length; i++) tmp.push(this[i].parentElement);
    +		return S(tmp);
    +	};
    +	stuQuery.prototype.children = function(c){
    +		var i;
    +		// Only look one level down
    +		if(typeof c==="string"){
    +			// We are using a selector
    +			var result = [];
    +			for(i = 0; i < this.length; i++){
    +				for(var ch = 0; ch < this[i].children.length; ch++){
    +					if(this.matchSelector(this[i].children[ch],c)) result.push(this[i].children[ch]);
    +				}
    +			}
    +			return S(result);
    +		}else{
    +			// We are using an index
    +			for(i = 0; i < this.length; i++) this[i] = (this[i].children.length > c ? this[i].children[c] : this[i]);
    +			return this;
    +		}
    +	};
    +	stuQuery.prototype.find = function(selector){
    +		var result = [];
    +		for(var i = 0; i < this.length; i++) result = result.concat(this.querySelector(this[i],selector));
    +		// Return a new instance of stuQuery
    +		return S(result);
    +	};
    +	function getset(s,attr,val,typs){
    +		var tmp = [];
    +		for(var i = 0; i < s.length; i++){
    +			tmp.push(s[i].getAttribute(attr));
    +			var ok = false;
    +			for(var j in typs){ if(typeof val===typs[j]) ok = true; }
    +			if(ok){
    +				if(val) s[i].setAttribute(attr,val);
    +				else s[i].removeAttribute(attr);
    +			}
    +		}
    +		if(tmp.length==1) tmp = tmp[0];
    +		if(typeof val==="undefined") return tmp;
    +		else return s;
    +	}
    +	stuQuery.prototype.attr = function(attr,val){
    +		return getset(this,attr,val,["string","number"]);
    +	};
    +	stuQuery.prototype.prop = function(attr,val){
    +		return getset(this,attr,val,["boolean"]);
    +	};
    +	stuQuery.prototype.clone = function(){
    +		var span = document.createElement('div');
    +		span.appendChild(this[0].cloneNode(true));
    +		return span.innerHTML;
    +	};
    +	stuQuery.prototype.replaceWith = function(html){
    +		var tempDiv;
    +		var clone = S(this.e);
    +		for(var i = 0; i < this.length; i++){
    +			tempDiv = document.createElement('div');
    +			tempDiv.innerHTML = html;
    +			clone[i] = tempDiv.cloneNode(true);
    +			this[i].parentNode.replaceChild(clone[i], this[i]);
    +		}
    +		return clone;
    +	};
    +	stuQuery.prototype.width = function(){
    +		if(this.length > 1) return;
    +		return this[0].offsetWidth;
    +	};
    +	stuQuery.prototype.height = function(){
    +		if(this.length > 1) return;
    +		return this[0].offsetHeight;
    +	};
    +	stuQuery.prototype.outerWidth = function(){
    +		if(this.length > 1) return;
    +		var s = getComputedStyle(this[0]);
    +		return this[0].offsetWidth + parseInt(s.marginLeft) + parseInt(s.marginRight);
    +	};
    +	stuQuery.prototype.outerHeight = function(){
    +		if(this.length > 1) return;
    +		var s = getComputedStyle(this[0]);
    +		return this[0].offsetHeight + parseInt(s.marginTop) + parseInt(s.marginBottom);
    +	};
    +	stuQuery.prototype.offset = function(){
    +		var rect = this[0].getBoundingClientRect();
    +	
    +		return {
    +		  top: rect.top + document.body.scrollTop,
    +		  left: rect.left + document.body.scrollLeft
    +		};
    +	};
    +	stuQuery.prototype.position = function(){
    +		if(this.length > 1) return;
    +		return {left: this[0].offsetLeft, top: this[0].offsetTop};
    +	};
    +	stuQuery.prototype.ajax = function(url,attrs){
    +		//=========================================================
    +		// ajax(url,{'complete':function,'error':function,'dataType':'json'})
    +		// complete: function - a function executed on completion
    +		// error: function - a function executed on an error
    +		// cache: break the cache
    +		// dataType: json - will convert the text to JSON
    +		//           jsonp - will add a callback function and convert the results to JSON
    +
    +		if(typeof url!=="string") return false;
    +		if(!attrs) attrs = {};
    +		var cb = "",qs = "";
    +		var oReq,urlbits;
    +		// If part of the URL is query string we split that first
    +		if(url.indexOf("?") > 0){
    +			urlbits = url.split("?");
    +			if(urlbits.length){
    +				url = urlbits[0];
    +				qs = urlbits[1];
    +			}
    +		}
    +		if(attrs.dataType=="jsonp"){
    +			cb = 'fn_'+(new Date()).getTime();
    +			window[cb] = function(rsp){
    +				if(typeof attrs.success==="function") attrs.success.call((attrs['this'] ? attrs['this'] : this), rsp, attrs);
    +			};
    +		}
    +		if(typeof attrs.cache==="boolean" && !attrs.cache) qs += (qs ? '&':'')+(new Date()).valueOf();
    +		if(cb) qs += (qs ? '&':'')+'callback='+cb;
    +		if(attrs.data) qs += (qs ? '&':'')+attrs.data;
    +
    +		// Build the URL to query
    +		if(attrs.method=="POST") attrs.url = url;
    +		else attrs.url = url+(qs ? '?'+qs:'');
    +
    +		if(attrs.dataType=="jsonp"){
    +			var script = document.createElement('script');
    +			script.src = attrs.url;
    +			document.body.appendChild(script);
    +			return this;
    +		}
    +
    +		// code for IE7+/Firefox/Chrome/Opera/Safari or for IE6/IE5
    +		oReq = (window.XMLHttpRequest) ? new XMLHttpRequest() : new ActiveXObject("Microsoft.XMLHTTP");
    +		oReq.addEventListener("load", window[cb] || complete);
    +		oReq.addEventListener("error", error);
    +		oReq.addEventListener("progress", progress);
    +		var responseTypeAware = 'responseType' in oReq;
    +		if(attrs.beforeSend) oReq = attrs.beforeSend.call((attrs['this'] ? attrs['this'] : this), oReq, attrs);
    +
    +		function complete(evt) {
    +			attrs.header = oReq.getAllResponseHeaders();
    +			var rsp;
    +			if(oReq.status == 200 || oReq.status == 201 || oReq.status == 202) {
    +				rsp = oReq.response;
    +				if(oReq.responseType=="" || oReq.responseType=="text") rsp = oReq.responseText;
    +				if(attrs.dataType=="json"){
    +					try {
    +						if(typeof rsp==="string") rsp = JSON.parse(rsp.replace(/[\n\r]/g,"\\n").replace(/^([^\(]+)\((.*)\)([^\)]*)$/,function(e,a,b,c){ return (a==cb) ? b:''; }).replace(/\\n/g,"\n"));
    +					} catch(e){ error(e); }
    +				}
    +
    +				// Parse out content in the appropriate callback
    +				if(attrs.dataType=="script"){
    +					var fileref=document.createElement('script');
    +					fileref.setAttribute("type","text/javascript");
    +					fileref.innerHTML = rsp;
    +					document.head.appendChild(fileref);
    +				}
    +				attrs.statusText = 'success';
    +				if(typeof attrs.success==="function") attrs.success.call((attrs['this'] ? attrs['this'] : this), rsp, attrs);
    +			}else{
    +				attrs.statusText = 'error';
    +				error(evt);
    +			}
    +			if(typeof attrs.complete==="function") attrs.complete.call((attrs['this'] ? attrs['this'] : this), rsp, attrs);
    +		}
    +
    +		function error(evt){
    +			if(typeof attrs.error==="function") attrs.error.call((attrs['this'] ? attrs['this'] : this),evt,attrs);
    +		}
    +
    +		function progress(evt){
    +			if(typeof attrs.progress==="function") attrs.progress.call((attrs['this'] ? attrs['this'] : this),evt,attrs);
    +		}
    +
    +		// ECC: "script" gives warning message
    +		if(responseTypeAware && attrs.dataType && attrs.dataType !== "script"){
    +			try { oReq.responseType = attrs.dataType; }
    +			catch(err){ error(err); }
    +		}
    +
    +		try{ oReq.open((attrs.method||'GET'), attrs.url, true); }
    +		catch(err){ error(err); }
    +
    +		if(attrs.method=="POST") oReq.setRequestHeader('Content-type','application/x-www-form-urlencoded');
    +
    +		try{ oReq.send((attrs.method=="POST" ? qs : null)); }
    +		catch(err){ error(err); }
    +
    +		return this;
    +	};
    +	stuQuery.prototype.loadJSON = function(url,fn,attrs){
    +		if(!attrs) attrs = {};
    +		attrs.dataType = "json";
    +		attrs.complete = fn;
    +		this.ajax(url,attrs);
    +		return this;
    +	};
    +
    +	root.stuQuery = stuQuery;
    +	root.S = function(e){ return new stuQuery(e); };
    +
    +})(window || this);
    diff --git a/html/allsky/virtualsky/virtualsky-planets.js b/html/allsky/virtualsky/virtualsky-planets.js
    new file mode 100755
    index 000000000..bf236a233
    --- /dev/null
    +++ b/html/allsky/virtualsky/virtualsky-planets.js
    @@ -0,0 +1,392 @@
    +/*!
    +	Virtual Sky add on to display planets without 
    +	needing a file regularly updated from JPL Horizons
    +	Written by Stuart Lowe (http://www.strudel.org.uk/)
    + */
    +
    +(function(S){
    +
    +	// An init function for the plugin
    +	function init(){
    +		// Attach a callback to the loadedPlanets event to calculate and draw the planets
    +		this.bind("loadedPlanets",function(d){
    +			this.jd = this.times.JD;
    +			var p = new Planets();
    +			var days = 365.25;
    +			this.planets = p.build(Math.floor(this.jd)-days*0.25,days*1.25);
    +			var loadtime = this.times.JD;
    +			var i = this.calendarevents.length;
    +			this.calendarevents.push(function(){
    +				if(Math.abs(loadtime-this.times.JD) >= days*0.25){
    +					this.calendarevents.splice(i,1);	// Remove this one-time event
    +					this.trigger('loadedPlanets');
    +					this.draw("loadPlanets-calendarevents");		// ALLSKY ADDED argument
    +				}
    +			})
    +			this.draw("loadPlanets");		// ALLSKY ADDED argument
    +		});
    +	}
    +
    +	// Create an object to deal with planet ephemerides
    +	function Planets(){
    +		// Heliocentric Osculating Orbital Elements Referred to the Mean Equinox and Ecliptic of Date for 2013: http://asa.usno.navy.mil/static/files/2013/Osculating_Elements_2013.txt
    +		// Values of the Osculating Orbital Elements for 8th August 1997: http://www.stargazing.net/kepler/ellipse.html
    +		// Uncertainties in RA (pre 2050) should be: <400" (Jupiter); <600" (Saturn); <50" everything else
    +		// See also: https://ssd.jpl.nasa.gov/txt/p_elem_t1.txt
    +		//           https://ssd.jpl.nasa.gov/?planet_pos
    +		this.planets = [{
    +			"name": "Me",
    +			"radius":2439.7,	// km
    +			"interval": 0.5,
    +			"colour": "rgb(170,150,170)",
    +			"magnitude": function(d){ return -0.36 + 5*log10(d.r*d.R) + 0.027 * d.FV + 2.2E-13 * Math.pow(d.FV,6); },
    +			"elements": [
    +				{"jd":2456280.5,"i":7.0053,"o":48.485,"p":77.658,"a":0.387100,"n":4.09232,"e":0.205636,"L":191.7001},
    +				{"jd":2456360.5,"i":7.0052,"o":48.487,"p":77.663,"a":0.387098,"n":4.09235,"e":0.205646,"L":159.0899},
    +				{"jd":2456440.5,"i":7.0052,"o":48.490,"p":77.665,"a":0.387097,"n":4.09236,"e":0.205650,"L":126.4812},
    +				{"jd":2456520.5,"i":7.0052,"o":48.493,"p":77.669,"a":0.387098,"n":4.09235,"e":0.205645,"L":93.8725},
    +				{"jd":2456600.5,"i":7.0052,"o":48.495,"p":77.672,"a":0.387099,"n":4.09234,"e":0.205635,"L":61.2628},
    +				{"jd":2456680.5,"i":7.0052,"o":48.498,"p":77.677,"a":0.387098,"n":4.09234,"e":0.205633,"L":28.6524}
    +			]
    +		},{
    +			"name": "V",
    +			"radius": 6051.9,	// km
    +			"interval": 1,
    +			"colour": "rgb(245,222,179)",
    +			"magnitude": function(d){ return -4.34 + 5*log10(d.a*d.R) + 0.013 * d.FV + 4.2E-7*Math.pow(d.FV,3); },
    +			"elements": [
    +				{"jd":2456280.5,"i":3.3949,"o":76.797,"p":132.00,"a":0.723328,"n":1.60214,"e":0.006777,"L":209.0515},
    +				{"jd":2456360.5,"i":3.3949,"o":76.799,"p":132.07,"a":0.723327,"n":1.60215,"e":0.006787,"L":337.2248},
    +				{"jd":2456440.5,"i":3.3949,"o":76.802,"p":131.97,"a":0.723333,"n":1.60213,"e":0.006780,"L":105.3980},
    +				{"jd":2456520.5,"i":3.3949,"o":76.804,"p":131.99,"a":0.723327,"n":1.60215,"e":0.006769,"L":233.5729},
    +				{"jd":2456600.5,"i":3.3949,"o":76.807,"p":132.03,"a":0.723326,"n":1.60215,"e":0.006775,"L":1.7475},
    +				{"jd":2456680.5,"i":3.3948,"o":76.808,"p":131.63,"a":0.723345,"n":1.60209,"e":0.006770,"L":129.9169}
    +			]
    +		},{
    +			"name":"E",
    +			"elements" : [
    +				{"jd":2450680.5,"i":0.00041,"o":349.2,"p":102.8517,"a":1.0000200,"n":0.9855796,"e":0.0166967,"L":328.40353},
    +				{"jd":2456320.5,"i":0.0,"o":349.2,"p":103.005,"a":0.999986,"n":0.985631,"e":0.016682,"L":127.4201},
    +				{"jd":2456400.5,"i":0.0,"o":349.2,"p":103.022,"a":0.999987,"n":0.985630,"e":0.016677,"L":206.2740},
    +				{"jd":2456480.5,"i":0.0,"o":349.2,"p":103.119,"a":1.000005,"n":0.985603,"e":0.016675,"L":285.1238},
    +				{"jd":2456560.5,"i":0.0,"o":349.2,"p":103.161,"a":0.999995,"n":0.985618,"e":0.016682,"L":3.9752},
    +				{"jd":2456680.5,"i":0.0,"o":349.2,"p":103.166,"a":1.000005,"n":0.985603,"e":0.016693,"L":122.2544}
    +			]
    +		},{
    +			"name":"Ma",
    +			"radius": 3386,	// km
    +			"interval": 1,
    +			"colour": "rgb(255,50,50)",
    +			"magnitude": function(d){ return -1.51 + 5*log10(d.r*d.R) + 0.016 * d.FV; },
    +			"elements":[
    +				{"jd":2450680.5,"i":1.84992,"o":49.5664,"p":336.0882,"a":1.5236365,"n":0.5240613,"e":0.0934231,"L":262.42784},
    +				{"jd":2456320.5,"i":1.8497,"o":49.664,"p":336.249,"a":1.523605,"n":0.524079,"e":0.093274,"L":338.1493},
    +				{"jd":2456400.5,"i":1.8497,"o":49.666,"p":336.268,"a":1.523627,"n":0.524068,"e":0.093276,"L":20.0806},
    +				{"jd":2456480.5,"i":1.8496,"o":49.668,"p":336.306,"a":1.523731,"n":0.524014,"e":0.093316,"L":62.0048},
    +				{"jd":2456560.5,"i":1.8495,"o":49.666,"p":336.329,"a":1.523748,"n":0.524005,"e":0.093385,"L":103.9196},
    +				{"jd":2456680.5,"i":1.8495,"o":49.665,"p":336.330,"a":1.523631,"n":0.524066,"e":0.093482,"L":166.8051}
    +			]
    +		},{
    +			"name":"J",
    +			"radius": 69173,	// km
    +			"interval": 1.5,	// ALLSKY: was 10, but caused Jupiter to not be displayed.  Got 1.5 via trial and error.
    +			"colour": "rgb(255,150,150)",
    +			"magnitude": function(d){ return -9.25 + 5*log10(d.r*d.R) + 0.014 * d.FV; },
    +			"elements":[
    +				{"jd":2456280.5,"i":1.3033,"o":100.624,"p":14.604,"a":5.20269,"n":0.083094,"e":0.048895,"L":68.0222},
    +				{"jd":2456360.5,"i":1.3033,"o":100.625,"p":14.588,"a":5.20262,"n":0.083095,"e":0.048895,"L":74.6719},
    +				{"jd":2456440.5,"i":1.3033,"o":100.627,"p":14.586,"a":5.20259,"n":0.083096,"e":0.048892,"L":81.3228},
    +				{"jd":2456520.5,"i":1.3033,"o":100.629,"p":14.556,"a":5.20245,"n":0.083099,"e":0.048892,"L":87.9728},
    +				{"jd":2456600.5,"i":1.3033,"o":100.631,"p":14.576,"a":5.20254,"n":0.083097,"e":0.048907,"L":94.6223},
    +				{"jd":2456680.5,"i":1.3033,"o":100.633,"p":14.592,"a":5.20259,"n":0.083096,"e":0.048891,"L":101.2751}
    +			]
    +		},{
    +			"name":"S",
    +			"radius": 57316,	// km
    +			"interval": 10,
    +			"colour": "rgb(200,150,150)",
    +			"magnitude": function(d){
    +				var slon = Math.atan2(d.y,d.x);
    +				var slat = Math.atan2(d.z, Math.sqrt(d.x*d.x + d.y*d.y));
    +				while(slon < 0){ slon += 2*Math.PI; }
    +				while(slon >= 360){ slon -= 2*Math.PI; }
    +				var ir = d.d2r*28.06;
    +				var Nr = d.d2r*(169.51 + 3.82E-5 * (d.jd-2451543.5));	// Compared to J2000 epoch
    +				var B = Math.asin(Math.sin(slat) * Math.cos(ir) - Math.cos(slat) * Math.sin(ir) * Math.sin(slon-Nr));
    +				return -9.0  + 5*log10(d.r*d.R) + 0.044 * d.FV + (-2.6 * Math.sin(Math.abs(B)) + 1.2 * Math.pow(Math.sin(B),2));
    +			},
    +			"elements":[
    +				{"jd":2456280.5,"i":2.4869,"o":113.732,"p":90.734,"a":9.51836,"n":0.033583,"e":0.055789,"L":208.6057},
    +				{"jd":2456360.5,"i":2.4869,"o":113.732,"p":90.979,"a":9.52024,"n":0.033574,"e":0.055794,"L":211.2797},
    +				{"jd":2456440.5,"i":2.4869,"o":113.732,"p":91.245,"a":9.52234,"n":0.033562,"e":0.055779,"L":213.9525},
    +				{"jd":2456520.5,"i":2.4869,"o":113.732,"p":91.500,"a":9.52450,"n":0.033551,"e":0.055724,"L":216.6279},
    +				{"jd":2456600.5,"i":2.4870,"o":113.732,"p":91.727,"a":9.52630,"n":0.033541,"e":0.055691,"L":219.3014},
    +				{"jd":2456680.5,"i":2.4870,"o":113.733,"p":92.021,"a":9.52885,"n":0.033528,"e":0.055600,"L":221.9730}
    +			]
    +		},{
    +			"name":"U",
    +			"radius": 25266,	// km
    +			"interval": 20,
    +			"colour": "rgb(130,150,255)",
    +			"magnitude": function(d){ return -7.15 + 5*log10(d.r*d.R) + 0.001 * d.FV; },
    +			"elements":[
    +				{"jd":2456280.5,"i":0.7726,"o":74.004,"p":169.227,"a":19.2099,"n":0.011713,"e":0.046728,"L":9.1400},
    +				{"jd":2456360.5,"i":0.7727,"o":73.997,"p":169.314,"a":19.2030,"n":0.011720,"e":0.047102,"L":10.0873},
    +				{"jd":2456440.5,"i":0.7728,"o":73.989,"p":169.434,"a":19.1953,"n":0.011727,"e":0.047509,"L":11.0340},
    +				{"jd":2456520.5,"i":0.7728,"o":73.989,"p":169.602,"a":19.1882,"n":0.011733,"e":0.047874,"L":11.9756},
    +				{"jd":2456600.5,"i":0.7728,"o":73.985,"p":169.740,"a":19.1816,"n":0.011739,"e":0.048215,"L":12.9200},
    +				{"jd":2456680.5,"i":0.7728,"o":73.983,"p":169.962,"a":19.1729,"n":0.011747,"e":0.048650,"L":13.8617}
    +			]
    +		},{
    +			"name":"N",
    +			"radius": 24553,	// km
    +			"interval": 20,
    +			"colour": "rgb(100,100,255)",
    +			"magnitude": function(d){ return -6.90 + 5*log10(d.r*d.R) + 0.001 * d.FV; },
    +			"elements":[
    +				{"jd":2456280.5,"i":1.7686,"o":131.930,"p":53.89,"a":30.0401,"n":0.005990,"e":0.010281,"L":333.6121},
    +				{"jd":2456360.5,"i":1.7688,"o":131.935,"p":56.47,"a":30.0259,"n":0.005994,"e":0.010138,"L":334.0856},
    +				{"jd":2456440.5,"i":1.7690,"o":131.940,"p":59.24,"a":30.0108,"n":0.005999,"e":0.009985,"L":334.5566},
    +				{"jd":2456520.5,"i":1.7692,"o":131.946,"p":61.52,"a":29.9987,"n":0.006002,"e":0.009816,"L":335.0233},
    +				{"jd":2456600.5,"i":1.7694,"o":131.951,"p":63.84,"a":29.9867,"n":0.006006,"e":0.009690,"L":335.4937},
    +				{"jd":2456680.5,"i":1.7697,"o":131.957,"p":66.66,"a":29.9725,"n":0.006010,"e":0.009508,"L":335.9564}
    +			]
    +		}];
    +	
    +		this.d2r = Math.PI/180;
    +		this.r2d = 180/Math.PI;
    +		this.AUinkm = 149597870.700;
    +		return this;
    +	}
    +
    +	// Build an array containing all the planets
    +	// Inputs:
    +	//  jd = the Julian Date to calculate from
    +	//  days = the number of days to calculate ephemerides for
    +	Planets.prototype.build = function(jd,days){
    +		var arr = new Array(this.planets.length-1);
    +		var b = 0;
    +		if(!days) days = 365.25;
    +		for(var a = 0 ; a < this.planets.length ; a++){
    +			if(this.planets[a].colour) arr[b++] = this.buildPlanet(a,jd,days);
    +		}
    +		return arr;
    +	}
    +	
    +	// Build the data array for a particular planet
    +	// Inputs:
    +	//  planet = the ID of the planet
    +	//  jd = the Julian Date to calculate from
    +	//  days = the number of days to calculate ephemerides for
    +	Planets.prototype.buildPlanet = function(planet,jd,days){
    +		var p,coord,interval,n,jdcurr;
    +		if(typeof planet==="number"){
    +			p = planet;
    +		}else{
    +			var match = -1;
    +			for(var a = 0 ; a < this.planets.length ; a++){
    +				if(this.planets[a].name==planet) match = a;
    +			}
    +			if(match < 0) return this;
    +			if(match == 2) return this;	// Can't calculate Earth
    +			p = match;
    +		}
    +	
    +		interval = (typeof this.planets[p].interval==="number" ? this.planets[p].interval : 1);
    +	
    +		// Build an array of the form:
    +		// [Planet name,colour,[jd_1, ra_1, dec_1, mag_1, jd_2, ra_2, dec_2, mag_2....]]
    +		n = Math.floor(days/interval);
    +		var arr = new Array(3);
    +		arr[0] = this.planets[p]["name"];
    +		arr[1] = this.planets[p]["colour"];
    +		arr[2] = new Array(n*4);
    +
    +		jdcurr = jd;
    +		for(var i = 0 ; i < n; i++){
    +			jdcurr += interval;
    +			coord = this.getEphem(p,jdcurr);
    +			arr[2][i*4+0] = jdcurr;
    +			arr[2][i*4+1] = coord[0];
    +			arr[2][i*4+2] = coord[1];
    +			arr[2][i*4+3] = coord[2];
    +		}
    +		return arr;
    +	}
    +	
    +	
    +	// Get the ephemeris for the specified planet number
    +	// Input:
    +	//   planet = ID
    +	//   day = Julian Date to calculate the ephemeris for
    +	// Method from http://www.stargazing.net/kepler/ellipse.html#twig06
    +	Planets.prototype.getEphem = function(planet,day){
    +	
    +		var i,v,e,x,y,z,ec,q,ra,dc,R,mag,FV,phase;
    +	
    +		if(typeof planet==="number"){
    +			i = planet;
    +		}else{
    +			var match = -1;
    +			for(var a = 0 ; a < this.planets.length ; a++){
    +				if(this.planets[a].name==planet) match = a;
    +			}
    +			if(match < 0) return this;
    +			if(match == 2) return this;	// Can't calculate Earth
    +			i = match;
    +		}
    +
    +		// Heliocentric coordinates of planet
    +		v = this.getHeliocentric(this.planets[i],day);
    +	
    +		// Heliocentric coordinates of Earth
    +		e = this.getHeliocentric(this.planets[2],day);
    +	
    +		// Geocentric ecliptic coordinates of the planet
    +		x = v.xyz[0] - e.xyz[0];
    +		y = v.xyz[1] - e.xyz[1];
    +		z = v.xyz[2] - e.xyz[2];
    +	
    +		// Geocentric equatorial coordinates of the planet
    +		ec = 23.439292*this.d2r; // obliquity of the ecliptic for the epoch the elements are referred to
    +		q = [x,y * Math.cos(ec) - z * Math.sin(ec),y * Math.sin(ec) + z * Math.cos(ec)];
    +	
    +		ra = Math.atan(q[1]/q[0])*this.r2d;
    +		if(q[0] < 0) ra += 180;
    +		if(q[0] >= 0 && q[1] < 0) ra += 360;
    +	
    +		dc = Math.atan(q[2] / Math.sqrt(q[0]*q[0] + q[1]*q[1]))*this.r2d;
    +	
    +		R = Math.sqrt(q[0]*q[0] + q[1]*q[1] + q[2]*q[2]);
    +	
    +		// Calculate the magnitude (http://stjarnhimlen.se/comp/tutorial.html)
    +		var angdiam = (this.planets[i].radius*2/(R*this.AUinkm));
    +		mag = 1;
    +	
    +		// planet's heliocentric distance, v.r, its geocentric distance, R, and the distance to the Sun, e.r.
    +		FV = Math.acos( ( v.r*v.r + R*R - e.r*e.r ) / (2*v.r*R) );
    +		phase = (1 + Math.cos(FV))/2;
    +		mag = this.planets[i].magnitude({a:v.r,r:v.r,R:R,FV:FV*this.r2d,x:x,y:y,z:z,jd:day,d2r:this.d2r});
    +
    +		return [ra,dc,mag];
    +	}
    +	
    +	Planets.prototype.getHeliocentric = function(planet,jd,i){
    +		var min = 1e10;
    +		var mn,p,d,M,v,r;
    +	
    +		// Choose a set of orbital elements
    +		if(!i){
    +			// Loop over elements and pick the one closest in time
    +			for(var j = 0; j < planet.elements.length ;j++){
    +				mn = Math.abs(planet.elements[j].jd-jd);
    +				if(mn < min){
    +					i = j;
    +					min = mn;
    +				}
    +			}
    +		}
    +		p = planet.elements[i];
    +
    +		// The day number is the number of days (decimal) since epoch of elements.
    +		d = (jd - p.jd);
    +	
    +		// Heliocentric coordinates of planet
    +		M = this.meanAnomaly(p.n,d,p.L,p.p)
    +		v = this.trueAnomaly(M*this.d2r,p.e,10);
    +		r = p.a * (1 - Math.pow(p.e,2)) / (1 + p.e * Math.cos(v*this.d2r));
    +		return {xyz: this.heliocentric(v*this.d2r,r,p.p*this.d2r,p.o*this.d2r,p.i*this.d2r), M:M, v:v, r:r, i:i, d:d, elements:p};
    +	}
    +	
    +	// Find the Mean Anomaly (M, degrees) of the planet where
    +	//  n is daily motion
    +	//  d is the number of days since the date of the elements
    +	//  L is the mean longitude (deg)
    +	//  p is the longitude of perihelion (deg) 
    +	//  M should be in range 0 to 360 degrees
    +	Planets.prototype.meanAnomaly = function(d,n,L,p){
    +		var M = n * d + L - p;
    +		while(M < 0){ M += 360; }
    +		while(M >= 360){ M -= 360; }
    +		return M;
    +	}
    +	
    +	// Heliocentric coordinates of the planet where:	
    +	//  o is longitude of ascending node (radians)
    +	//  p is longitude of perihelion (radians)
    +	//  i is inclination of plane of orbit (radians)
    +	// the quantity v + o - p is the angle of the planet measured in the plane of the orbit from the ascending node
    +	Planets.prototype.heliocentric = function(v,r,p,o,i){
    +		var vpo = v + p - o;
    +		var svpo = Math.sin(vpo);
    +		var cvpo = Math.cos(vpo);
    +		var co = Math.cos(o);
    +		var so = Math.sin(o);
    +		var ci = Math.cos(i);
    +		var si = Math.sin(i);
    +		return [r * (co * cvpo - so * svpo * ci),r * (so * cvpo + co * svpo * ci),r * (svpo * si)]
    +	}
    +	
    +	/*
    +		Find the True Anomaly given
    +		m  -  the 'mean anomaly' in orbit theory (in radians)
    +		ecc - the eccentricity of the orbit
    +	*/
    +	Planets.prototype.trueAnomaly = function(m,ecc,eps){
    +		var e = m;        // first guess
    +	
    +		if(typeof eps==="number"){
    +			var delta = 0.05; // set delta equal to a dummy value
    +			var eps = 10;     // eps - the precision parameter - solution will be within 10^-eps of the true value. Don't set eps above 14, as convergence can't be guaranteed
    +		
    +			while(Math.abs(delta) >= Math.pow(10,-eps)){    // converged?
    +				delta = e - ecc * Math.sin(e) - m;          // new error
    +				e -= delta / (1 - ecc * Math.cos(e));    // corrected guess
    +			}
    +			var v = 2 * Math.atan(Math.pow(((1 + ecc) / (1 - ecc)),0.5) * Math.tan(0.5 * e));
    +			if(v < 0) v+= Math.PI*2;
    +		}else{
    +			v = m + ( (2 * ecc - Math.pow(ecc,3)/4)*Math.sin(m) + 1.25*Math.pow(ecc,2)*Math.sin(2*m) + (13/12)*Math.pow(ecc,3)*Math.sin(3*m) );
    +		}
    +		return v*this.r2d; // return estimate
    +	}
    +	
    +	function formatRADec(ra,dec){
    +		var rah,ram,ras,dcd,dcm,dcs;
    +		ra /= 15;
    +		rah = Math.floor(ra);
    +		ram = Math.floor((ra-rah)*60);
    +		ras = (ra-rah-ram/60)*3600;
    +		dcd = Math.floor(dec);
    +		dcm = Math.floor((dec-dcd)*60);
    +		dcs = (dec-dcd-dcm/60)*3600;
    +		return (Math.abs(rah) < 10 ? "0":"")+rah+":"+(ram < 10 ? "0":"")+ram+":"+(ras < 10 ? "0":"")+ras.toFixed(2)+" "+(Math.abs(dcd) < 10 ? "0":"")+dcd+":"+(dcm < 10 ? "0":"")+dcm+":"+(dcs < 10 ? "0":"")+dcs.toFixed(2);
    +	}
    +	
    +	function getJD(today){
    +		if(!today) today = new Date();
    +		return ( today.getTime() / 86400000.0 ) + 2440587.5;
    +	}
    +	
    +	function rev(x) {
    +	  return  x - Math.floor(x/360.0)*360.0
    +	}
    +	
    +	function log10(x) {
    +		return Math.LOG10E * Math.log(x);
    +	}
    +
    +	var match = false;
    +	for(var i = 0; i < S.virtualsky.plugins.length; i++){
    +		if(S.virtualsky.plugins[i].name=="planets") match = true;
    +	}
    +
    +	if(!match){
    +		S.virtualsky.plugins.push({
    +			init: init,
    +			name: 'planets',
    +			version: '1.0'
    +		});
    +	}
    +
    +})(S);
    diff --git a/html/allsky/virtualsky/virtualsky.js b/html/allsky/virtualsky/virtualsky.js
    new file mode 100755
    index 000000000..776a8b642
    --- /dev/null
    +++ b/html/allsky/virtualsky/virtualsky.js
    @@ -0,0 +1,3747 @@
    +/*!
    +	Virtual Sky
    +	(c) Stuart Lowe, Las Cumbres Observatory Global Telescope
    +	A browser planetarium using HTML5's <canvas>.
    +*/
    +/*
    +	USAGE: See http://slowe.github.io/VirtualSky/
    +
    +	OPTIONS (default values in brackets):
    +		id ('starmap') - The ID for the HTML element where you want the sky inserted
    +		projection ('polar') - The projection type as 'polar', 'stereo', 'lambert', 'equirectangular', or 'ortho'
    +		width (500) - Set the width of the sky unless you've set the width of the element
    +		height (250) - Set the height of the sky unless you've set the height of the element
    +		planets - either an object containing an array of planets or a JSON file
    +		magnitude (5) - the magnitude limit of displayed stars
    +		longitude (53.0) - the longitude of the observer
    +		latitude (-2.5) - the latitude of the observer
    +		clock (now) - a Javascript Date() object with the starting date/time
    +		background ('rgba(0,0,0,0)') - the background colour
    +		transparent (false) - make the sky background transparent
    +		color ('rgb(255,255,255)') - the text colour
    +		az (180) - an azimuthal offset with 0 = north and 90 = east
    +		ra (0 <= x < 360) - the RA for the centre of the view in gnomic projection
    +		dec (-90 < x < 90) - the Declination for the centre of the view in gnomic projection
    +		negative (false) - invert the default colours i.e. to black on white
    +		ecliptic (false) - show the Ecliptic line
    +		meridian (false) - show the Meridian line
    +		gradient (true) - reduce the brightness of stars near the horizon
    +		cardinalpoints (true) - show/hide the N/E/S/W labels
    +		constellations (false) - show/hide the constellation lines
    +		constellationlabels (false) - show/hide the constellation labels
    +		constellationboundaries (false) - show/hide the constellation boundaries (IAU)
    +		constellationwidth (0.75) - pixel width of the constellation lines
    +		constellationboundarieswidth (0.75) - pixel width of the constellation boundary lines
    +		showstars (true) - show/hide the stars
    +		showstarlabels (false) - show/hide the star labels for brightest stars
    +		showplanets (true) - show/hide the planets
    +		showplanetlabels (true) - show/hide the planet labels
    +		showorbits (false) - show/hide the orbits of the planets
    +		showgalaxy (false) - show/hide an outline of the plane of the Milky Way
    +		showdate (true) - show/hide the date and time
    +		showposition (true) - show/hide the latitude/longitude
    +		ground (false) - show/hide the local ground (for full sky projections)
    +		keyboard (true) - allow keyboard controls
    +		mouse (true) - allow mouse controls
    +		gridlines_az (false) - show/hide the azimuth/elevation grid lines
    +		gridlines_eq (false) - show/hide the RA/Dec grid lines
    +		gridlines_gal (false) - show/hide the Galactic Coordinate grid lines
    +		gridstep (30) - the size of the grid step when showing grid lines
    +		gridlineswidth (0.75) - pixel width of the grid lines
    +		galaxywidth (0.75) - pixel width of the galaxy outline
    +		live (false) - update the display in real time
    +		fontsize - set the font size in pixels if you want to over-ride the auto sizing
    +		fontfamily - set the font family using a CSS style font-family string otherwise it inherits from the container element
    +		objects - a semi-colon-separated string of recognized object names to display e.g. "M1;M42;Horsehead Nebula" (requires internet connection)
    +*/
    +(function (S) {
    +
    +/*@cc_on
    +// Fix for IE's inability to handle arguments to setTimeout/setInterval
    +// From http://webreflection.blogspot.com/2007/06/simple-settimeout-setinterval-extra.html
    +(function(f){
    +	window.setTimeout =f(window.setTimeout);
    +	window.setInterval =f(window.setInterval);
    +})(function(f){return function(c,t){var a=[].slice.call(arguments,2);return f(function(){c.apply(this,a)},t)}});
    +@*/
    +// Define a shortcut for checking variable types
    +function is(a,b){return typeof a===b;}
    +function isEventSupported(eventName) {
    +    var el = document.createElement('div');
    +    eventName = 'on' + eventName;
    +    var isSupported = (eventName in el);
    +    if (!isSupported) {
    +        el.setAttribute(eventName, 'return;');
    +        isSupported = typeof el[eventName] == 'function';
    +    }
    +    el = null;
    +    return isSupported;
    +}
    +
    +
    +// Add extra stuQuery functions
    +stuQuery.prototype.val = function(v){
    +	if(this[0]){
    +		if(typeof v==="undefined") return this[0].value || S(this[0]).attr('value');
    +		else return S(this[0]).attr('value',v || '');
    +	}
    +	return "";
    +};
    +stuQuery.prototype.hide = function(){
    +	for(var i = 0; i < this.length; i++) S(this[i]).css({'display':'none'});
    +};
    +stuQuery.prototype.show = function(){
    +	for(var i = 0; i < this.length; i++) S(this[i]).css({'display':'block'});
    +};
    +stuQuery.prototype.animate = function(end,ms,fn){
    +	var anim,i,p;
    +	var initial = new Array(this.length);
    +	var els = new Array(this.length);
    +	var props = JSON.stringify(end);
    +
    +	// Create a structure of starting values
    +	for(i = 0; i < this.length; i++){
    +		els[i] = S(this[i]);
    +		initial[i] = JSON.parse(props);
    +		for(p in initial[i]){
    +			if(initial[i][p]) initial[i][p] = parseFloat(els[i].css(p));
    +		}
    +	}
    +
    +	var start = new Date();
    +	var _obj = this;
    +
    +	function change(){
    +		var i,p,v,f;
    +		var elapsed = new Date() - start;
    +		f = (elapsed < ms) ? (elapsed/ms) : 1;
    +		for(i = 0; i < _obj.length; i++){
    +			v = JSON.parse(JSON.stringify(initial[i]));
    +			for(p in v){
    +				if(f >= 1) v[p] = end[p].toFixed(4);
    +				else v[p] = (initial[i][p] + (f * (end[p] - initial[i][p]))).toFixed(4);
    +			}
    +			els[i].css(v);
    +		}
    +		if(f >= 1){
    +			clearInterval(anim);
    +			if(typeof fn==="function") fn.call(_obj);
    +		}
    +	}
    +	anim = setInterval(change,25);
    +	return;
    +};
    +stuQuery.prototype.fadeIn = function(ms,fn){
    +	return this.animate({'opacity':this.opacity},ms,fn);
    +};
    +stuQuery.prototype.fadeOut = function(ms,fn){
    +	return this.animate({'opacity':0},ms,fn);
    +};
    +
    +// Get the URL query string and parse it
    +S.query = function() {
    +	var r = {length:0};
    +	var q = location.search;
    +	if(q && q != '#'){
    +		// remove the leading ? and trailing &
    +		q.replace(/^\?/,'').replace(/\&$/,'').split('&').forEach(function(e){
    +			var key = e.split('=')[0];
    +			var val = e.split('=')[1];
    +			// convert floats
    +			if(/^-?[0-9.]+$/.test(val)) val = parseFloat(val);
    +			if(val == "true") val = true;
    +			if(val == "false") val = false;
    +			if(/^\?[0-9\.]+$/.test(val)) val = parseFloat(val);	// convert floats
    +			r[key] = val;
    +		});
    +	}
    +	return r;
    +};
    +
    +// Full Screen API - http://johndyer.name/native-fullscreen-javascript-api-plus-jquery-plugin/
    +var fullScreenApi = {
    +		supportsFullScreen: false,
    +		isFullScreen: function() { return false; },
    +		requestFullScreen: function() {},
    +		cancelFullScreen: function() {},
    +		fullScreenEventName: '',
    +		prefix: ''
    +	},
    +	browserPrefixes = 'webkit moz o ms khtml'.split(' ');
    +
    +// check for native support
    +if(typeof document.cancelFullScreen != 'undefined') {
    +	fullScreenApi.supportsFullScreen = true;
    +} else if (typeof document['msExitFullscreen'] != 'undefined') {
    +	fullScreenApi.prefix = 'ms';
    +	fullScreenApi.supportsFullScreen = true;
    +}else{
    +	// check for fullscreen support by vendor prefix
    +	for(var i = 0, il = browserPrefixes.length; i < il; i++ ) {
    +		fullScreenApi.prefix = browserPrefixes[i];
    +		if(typeof document[fullScreenApi.prefix + 'CancelFullScreen' ] != 'undefined' ) {
    +			fullScreenApi.supportsFullScreen = true;
    +			break;
    +		}
    +	}
    +}
    +
    +// update methods to do something useful
    +if(fullScreenApi.supportsFullScreen) {
    +	fullScreenApi.fullScreenEventName = fullScreenApi.prefix + 'fullscreenchange';
    +
    +	fullScreenApi.isFullScreen = function() {
    +		switch (this.prefix) {
    +			case '':
    +				return document.fullScreen;
    +			case 'webkit':
    +				return document.webkitIsFullScreen;
    +			case 'ms':
    +				return document.msFullscreenElement != null;
    +			default:
    +				return document[this.prefix + 'FullScreen'];
    +		}
    +	};
    +	fullScreenApi.requestFullScreen = function(el) {
    +		return (this.prefix === '') ? el.requestFullScreen() : ((this.prefix === 'ms') ? el.msRequestFullscreen() : el[this.prefix + 'RequestFullScreen']());
    +	};
    +	fullScreenApi.cancelFullScreen = function(el) {
    +		switch (this.prefix) {
    +			case '':
    +				return document.cancelFullScreen();
    +			case 'ms':
    +				return document.msExitFullscreen();
    +			default:
    +				return document[this.prefix + 'CancelFullScreen']();
    +		}
    +	};
    +}
    +
    +// export api
    +window.fullScreenApi = fullScreenApi;
    +// End of Full Screen API
    +
    +/*! VirtualSky */
    +var interval = undefined;	// ALLSKY ADDED: "interval" needs to be global
    +function VirtualSky(input){
    +
    +	this.version = "0.7.7";
    +
    +	this.ie = false;
    +	this.excanvas = (typeof G_vmlCanvasManager != 'undefined') ? true : false;
    +	/*@cc_on
    +	this.ie = true
    +	@*/
    +
    +	// Identify the default base directory
    +	this.setDir = function(){
    +		var d = S('script[src*=virtualsky]').attr('src')[0].match(/^.*\//);
    +		this.dir = d && d[0] || "";
    +		return;
    +	};
    +	this.getDir = function(pattern){
    +		if(typeof pattern!=="string") pattern = "virtualsky";
    +		var d = S('script[src*='+pattern+']').attr('src');
    +		if(typeof d==="string") d = [d];
    +		if(d.length < 1) d = [""];
    +		d = d[0].match(/^.*\//);
    +		return d && d[0] || "";
    +	};
    +
    +	this.q = S.query();    // Query string
    +	this.setDir();	// Set the default base directory
    +	this.dir = this.getDir();  // the JS file path
    +	this.langurl = this.dir + "lang/%LANG%.json";	// The location of the language files
    +
    +	this.id = '';						// The ID of the canvas/div tag - if none given it won't display
    +	this.gradient = true;				// Show the sky gradient
    +	this.magnitude = 5;					// Limit for stellar magnitude
    +	this.background = "rgba(0,0,0,0)";	// Default background colour is transparent
    +	this.color = "";					// Default background colour is chosen automatically
    +	this.wide = 0;						// Default width if not set in the <canvas> <div> or input argument
    +	this.tall = 0;
    +	this.opacity = 1;	// ALLSKY ADDED.  Let users specify via config file
    +
    +	// Constants
    +	this.d2r = Math.PI/180;
    +	this.r2d = 180.0/Math.PI;
    +
    +	// Set location on the Earth
    +	this.setLongitude(-119.86286);
    +	this.setLatitude(34.4326);
    +
    +	// Toggles
    +	this.spin = false;
    +	this.cardinalpoints = true;			// Display N, E, S and W.
    +	this.constellation = { lines: false, boundaries: false, labels: false };	// Display constellations
    +	this.meteorshowers = false;			// Display meteor shower radiants
    +	this.negative = false;				// Invert colours to make it better for printing
    +	this.showgalaxy = false;			// Display the Milky Way
    +	this.showstars = true;				// Display current positions of the stars
    +	this.showstarlabels = false;		// Display names for named stars
    +	this.showplanets = true;			// Display current positions of the planets
    +	this.showplanetlabels = true;		// Display names for planets
    +	this.showorbits = false;			// Display the orbital paths of the planets
    +	this.showdate = true;				// Display the current date
    +	this.showposition = true;			// Display the longitude/latitude
    +	this.scalestars = 1;				// A scale factor by which to increase the star sizes
    +	this.ground = false;
    +	this.grid = { az: false, eq: false, gal: false, step: 30 };	// Display grids
    +	this.gal = { 'processed':false, 'lineWidth':0.75 };
    +	this.ecliptic = false;				// Display the Ecliptic
    +	this.meridian = false;				// Display the Meridian
    +	this.keyboard = true;				// Allow keyboard controls
    +	this.mouse = true;					// Allow mouse controls
    +	this.islive = false;				// Update the sky in real time
    +	this.fullscreen = false;			// Should it take up the full browser window
    +	this.transparent = false;			// Show the sky background or not
    +	this.fps = 10;						// Number of frames per second when animating
    +	this.credit = (location.host == "lco.global" && location.href.indexOf("/embed") < 0) ? false : true;
    +	this.callback = { geo:'', mouseenter:'', mouseout:'', contextmenu: '', cursor: '', click:'', draw: '' };
    +	this.lookup = {};
    +	this.keys = [];
    +	this.base = "";
    +	this.az_step = 0;
    +	this.az_off = 0;
    +	this.ra_off = 0;
    +	this.dc_off = 0;
    +	this.fov = 30;
    +	this.plugins = [];
    +	this.calendarevents = [];
    +	this.events = {};	// Let's add some default events
    +
    +	// Projections
    +	this.projections = {
    +		'polar': {
    +			title: 'Polar projection',
    +			azel2xy: function(az,el,w,h){
    +				var radius = h/2;
    +				var r = radius*((Math.PI/2)-el)/(Math.PI/2);
    +				return {x:(w/2-r*Math.sin(az)),y:(radius-r*Math.cos(az)),el:el};
    +			},
    +			xy2azel: function(x, y, w, h) {
    +				var radius = h/2;
    +
    +				var X = w/2-x;
    +				var Y = radius - y;
    +				// X = r * Math.sin(az)
    +				// Y = r * Math.cos(az)
    +				r = Math.sqrt(X*X + Y*Y);
    +				// r = radius*((Math.PI/2)-el)/(Math.PI/2);
    +				// el = (Math.PI/2) - r * (Math.PI/2) / radius
    +				var el = (Math.PI/2) - r * (Math.PI/2) / radius;
    +				if (el < 0) {
    +					return undefined;
    +				}
    +				var az = Math.atan2(X, Y);
    +				return [az, el];
    +			},
    +			polartype: true,
    +			atmos: true
    +		},
    +		'fisheye':{
    +			title: 'Fisheye polar projection',
    +			azel2xy: function(az,el,w,h){
    +				var radius = h/2;
    +				var r = radius*Math.sin(((Math.PI/2)-el)/2)/0.70710678;	// the field of view is bigger than 180 degrees
    +				return {x:(w/2-r*Math.sin(az)),y:(radius-r*Math.cos(az)),el:el};
    +			},
    +			xy2azel: function(x, y, w, h) {
    +				var radius = h/2;
    +
    +				var X = w/2-x;
    +				var Y = radius - y;
    +				r = Math.sqrt(X*X + Y*Y);
    +				if (r > radius) {
    +					return undefined;
    +				}
    +				var el = Math.PI/2 - 2 * Math.asin(r * 0.70710678 / radius);
    +				var az = Math.atan2(X, Y);
    +				return [az, el];
    +			},
    +			polartype:true,
    +			atmos: true
    +		},
    +		'ortho':{
    +			title: 'Orthographic polar projection',
    +			azel2xy: function(az,el,w,h){
    +				var radius = h/2;
    +				var r = radius*Math.cos(el);
    +				return {x:(w/2-r*Math.sin(az)),y:(radius-r*Math.cos(az)),el:el};
    +			},
    +			xy2azel: function(x, y, w, h) {
    +				var radius = h/2;
    +
    +				var X = w/2-x;
    +				var Y = radius - y;
    +				r = Math.sqrt(X*X + Y*Y);
    +				if (r > radius) {
    +					return undefined;
    +				}
    +				var el = Math.acos(r / radius);
    +				var az = Math.atan2(X, Y);
    +				return [az, el];
    +			},
    +			polartype:true,
    +			atmos: true
    +		},
    +		'stereo': {
    +			title: 'Stereographic projection',
    +			azel2xy: function(az,el,w,h){
    +				var f = 0.42;
    +				var sinel1 = 0;
    +				var cosel1 = 1;
    +				var cosaz = Math.cos((az-Math.PI));
    +				var sinaz = Math.sin((az-Math.PI));
    +				var sinel = Math.sin(el);
    +				var cosel = Math.cos(el);
    +				var k = 2/(1+sinel1*sinel+cosel1*cosel*cosaz);
    +				return {x:(w/2+f*k*h*cosel*sinaz),y:(h-f*k*h*(cosel1*sinel-sinel1*cosel*cosaz)),el:el};
    +			},
    +			xy2azel: function(x, y, w, h) {
    +				var f = 0.42;
    +				var sinel1 = 0;
    +				var cosel1 = 1;
    +				var X = (x - w/2) / h;
    +				var Y = (h - y)/h;
    +				var R = f;
    +
    +				var P = Math.sqrt(X * X + Y * Y);
    +				var c = 2 * Math.atan2(P, 2*R);
    +
    +				var el = Math.asin(Math.cos(c)*sinel1 + Y * Math.sin(c) * cosel1 / P);
    +				var az = Math.PI + Math.atan2(X * Math.sin(c), P * cosel1 * Math.cos(c) - Y * sinel1 * Math.sin(c));
    +
    +				return [az, el];
    +			},
    +			atmos: true
    +		},
    +		'lambert':{
    +			title: 'Lambert projection',
    +			azel2xy: function(az,el,w,h){
    +				var cosaz = Math.cos((az-Math.PI));
    +				var sinaz = Math.sin((az-Math.PI));
    +				var sinel = Math.sin(el);
    +				var cosel = Math.cos(el);
    +				var k = Math.sqrt(2/(1+cosel*cosaz));
    +				return {x:(w/2+0.6*h*k*cosel*sinaz),y:(h-0.6*h*k*(sinel)),el:el};
    +			},
    +			xy2azel: function(x, y, w, h) {
    +				var X = (x - w/2) / (0.6 * h);
    +				var Y = (h - y) / (0.6 * h);
    +
    +				var p = Math.sqrt(X * X + Y * Y);
    +				if (p > 2 || p < -2) {
    +					return undefined;
    +				}
    +				var c = 2 * Math.asin(p / 2);
    +
    +				var el = Math.asin(Y * Math.sin(c)/p);
    +				var az = Math.PI + Math.atan2(X * Math.sin(c), p * Math.cos(c));
    +
    +				return [az, el];
    +			},
    +			atmos: true
    +		},
    +		'gnomic': {
    +			title: 'Gnomic projection',
    +			azel2xy: function(az,el){
    +				if(el >= 0){
    +					var pos = this.azel2radec(az,el);
    +					return this.radec2xy(pos.ra*this.d2r,pos.dec*this.d2r,[el,az]);
    +				}else{
    +					return { x: -1, y: -1, el: el };
    +				}
    +			},
    +			radec2xy: function(ra,dec,coords){
    +
    +				var cd, cd0, sd, sd0, dA, A, F, scale;
    +
    +				// Only want to project the sky around the map centre
    +				if(Math.abs(dec-this.dc_off) > this.maxangle) return {x:-1,y:-1,el:-1};
    +				var ang = this.greatCircle(this.ra_off,this.dc_off,ra,dec);
    +				if(ang > this.maxangle) return {x:-1,y:-1,el:-1};
    +
    +				if(!coords) coords = this.coord2horizon(ra, dec);
    +
    +				// Should we show things below the horizon?
    +				if(this.ground && coords[0] < -1e-6) return {x:-1, y:-1, el:coords[0]*this.r2d};
    +
    +				// number of pixels per degree in the map
    +				scale = this.tall/this.fov;
    +
    +				cd = Math.cos(dec);
    +				cd0 = Math.cos(this.dc_off);
    +				sd = Math.sin(dec);
    +				sd0 = Math.sin(this.dc_off);
    +
    +				dA = ra-this.ra_off;
    +				dA = inrangeAz(dA);
    +
    +				A = cd*Math.cos(dA);
    +				F = scale*this.r2d/(sd0*sd + A*cd0);
    +
    +				return {x:(this.wide/2)-F*cd*Math.sin(dA),y:(this.tall/2) -F*(cd0*sd - A*sd0),el:coords[0]*this.r2d};
    +			},
    +			xy2radec: function(x,y){
    +
    +				// number of pixels per degree in the map
    +				var scale = this.tall/this.fov;
    +
    +				var X = ((this.wide/2) - x) / (scale*this.r2d);
    +				var Y = ((this.tall/2) - y) / (scale*this.r2d);
    +
    +				var p = Math.sqrt(X*X + Y*Y);
    +				var c = Math.atan(p);
    +
    +				var dec = Math.asin(Math.cos(c)*Math.sin(this.dc_off) + Y * Math.sin(c) * Math.cos(this.dc_off)/ p);
    +				var ra = this.ra_off + Math.atan2(X * Math.sin(c), (p * Math.cos(this.dc_off) * Math.cos(c) - Y * Math.sin(this.dc_off) * Math.sin(c)));
    +
    +				// Only want to project the sky around the map centre
    +				if(Math.abs(dec-this.dc_off) > this.maxangle) return undefined;
    +				var ang = this.greatCircle(this.ra_off,this.dc_off,ra,dec);
    +				if(ang > this.maxangle) return undefined;
    +
    +				var coords = this.coord2horizon(ra, dec);
    +
    +				// Should we show things below the horizon?
    +				if(this.ground && coords[0] < -1e-6) return undefined;
    +
    +				return {ra: ra, dec: dec};
    +			},
    +			draw: function(){
    +				if(!this.transparent){
    +					this.ctx.fillStyle = (this.hasGradient()) ? "rgba(0,15,30, 1)" : ((this.negative) ? this.col.white : this.col.black);
    +					this.ctx.fillRect(0,0,this.wide,this.tall);
    +					this.ctx.fill();
    +				}
    +			},
    +			isVisible: function(el){
    +				return true;
    +			},
    +			atmos: false,
    +			fullsky: true
    +		},
    +		'equirectangular':{
    +			title: 'Equirectangular projection',
    +			azel2xy: function(az,el,w,h){
    +				while(az < 0) az += 2*Math.PI;
    +				az = (az)%(Math.PI*2);
    +				return {x:(((az-Math.PI)/(Math.PI/2))*h + w/2),y:(h-(el/(Math.PI/2))*h),el:el};
    +			},
    +			xy2azel: function(x, y, w, h) {
    +				var az = (Math.PI/2) * (x - w / 2) / h + Math.PI;
    +				if (az < 0 || az > 2 * Math.PI) {
    +					return undefined;
    +				}
    +				var el = (Math.PI/2) * (h - y) / h;
    +				return [az, el];
    +			},
    +			maxb: 90,
    +			atmos: true
    +		},
    +		'mollweide':{
    +			title: 'Mollweide projection',
    +			radec2xy: function(ra,dec){
    +				var dtheta, x, y, coords, sign, outside, normra;
    +				var thetap = Math.abs(dec);
    +				var pisindec = Math.PI*Math.sin(Math.abs(dec));
    +				// Now iterate to correct answer
    +				for(var i = 0; i < 20 ; i++){
    +					dtheta = -(thetap + Math.sin(thetap) - pisindec)/(1+Math.cos(thetap));
    +					thetap += dtheta;
    +					if(dtheta < 1e-4) break;
    +				}
    +				normra = (ra+this.d2r*this.az_off)%(2*Math.PI) - Math.PI;
    +				outside = false;
    +				x = -(2/Math.PI)*(normra)*Math.cos(thetap/2)*this.tall/2 + this.wide/2;
    +				if(x > this.wide) outside = true;
    +				sign = (dec >= 0) ? 1 : -1;
    +				y = -sign*Math.sin(thetap/2)*this.tall/2 + this.tall/2;
    +				coords = this.coord2horizon(ra, dec);
    +				return {x:(outside ? -100 : x%this.wide),y:y,el:coords[0]*this.r2d};
    +			},
    +			xy2radec: function(x, y) {
    +				var X  = (this.wide/2 - x) * Math.sqrt(2) * 2 / this.tall;
    +				var Y = (this.tall/2 - y) * Math.sqrt(2) * 2 / this.tall;
    +
    +				var theta = Math.asin(Y / Math.sqrt(2));
    +				var dec = Math.asin((2 * theta + Math.sin(2 * theta)) / Math.PI);
    +				if (Math.abs(X) > 2 * Math.sqrt(2) * Math.cos(theta)) {
    +					// Out of bounds
    +					return undefined;
    +				}
    +				var ra = Math.PI - (this.d2r*this.az_off) + Math.PI * X / (2 * Math.sqrt(2) * Math.cos(theta));
    +				return {ra: ra, dec: dec};
    +			},
    +			draw: function(){
    +				var c = this.ctx;
    +				c.moveTo(this.wide/2,this.tall/2);
    +				c.beginPath();
    +				var x = this.wide/2-this.tall;
    +				var y = 0;
    +				var w = this.tall*2;
    +				var h = this.tall;
    +				var kappa = 0.5522848;
    +				var ox = (w / 2) * kappa; // control point offset horizontal
    +				var oy = (h / 2) * kappa; // control point offset vertical
    +				var xe = x + w;           // x-end
    +				var ye = y + h;           // y-end
    +				var xm = x + w / 2;       // x-middle
    +				var ym = y + h / 2;       // y-middle
    +				c.moveTo(x, ym);
    +				c.bezierCurveTo(x, ym - oy, xm - ox, y, xm, y);
    +				c.bezierCurveTo(xm + ox, y, xe, ym - oy, xe, ym);
    +				c.bezierCurveTo(xe, ym + oy, xm + ox, ye, xm, ye);
    +				c.bezierCurveTo(xm - ox, ye, x, ym + oy, x, ym);
    +				c.closePath();
    +				if(!this.transparent){
    +					c.fillStyle = (this.hasGradient()) ? "rgba(0,15,30, 1)" : ((this.negative) ? this.col.white : this.col.black);
    +					c.fill();
    +				}
    +			},
    +			altlabeltext:true,
    +			fullsky:true,
    +			atmos: false
    +		},
    +		'planechart':{
    +			title: 'Planechart projection',
    +			radec2xy: function(ra,dec){
    +				ra = inrangeAz(ra);
    +				var normra = (ra+this.d2r*this.az_off)%(2*Math.PI)-Math.PI;
    +				var x = -(normra/(2*Math.PI))*this.tall*2 + this.wide/2;
    +				var y = -(dec/Math.PI)*this.tall+ this.tall/2;
    +				var coords = this.coord2horizon(ra, dec);
    +				return {x:x,y:y,el:coords[0]*this.r2d};
    +			},
    +			xy2radec: function(x, y) {
    +				var normra = (this.wide / 2 - x)* 2 * Math.PI / (this.tall * 2);
    +				if (Math.abs(normra) > Math.PI) {
    +					return undefined;
    +				}
    +				var ra = normra + Math.PI - this.d2r*this.az_off;
    +				var dec = Math.PI * (this.tall / 2 - y) / this.tall;
    +				return {ra: ra, dec: dec};
    +			},
    +			draw: function(){
    +				if(!this.transparent){
    +					this.ctx.fillStyle = (this.hasGradient()) ? "rgba(0,15,30, 1)" : ((this.negative) ? this.col.white : this.col.black);
    +					this.ctx.fillRect((this.wide/2) - (this.tall),0,this.tall*2,this.tall);
    +					this.ctx.fill();
    +				}
    +			},
    +			fullsky:true,
    +			atmos: false
    +		}
    +	};
    +
    +	// Data for stars < mag 4.5 or that are a vertex for a constellation line - 20 kB [id, mag, right ascension, declination]
    +	// index with Hipparcos number
    +	this.stars = this.convertStarsToRadians([[677,2.1,2.097,29.09],[746,2.3,2.295,59.15],[765,3.9,2.353,-45.75],
    +					[1067,2.8,3.309,15.18],[1562,3.6,4.857,-8.82],[1599,4.2,5.018,-64.87],[1645,5.4,5.149,8.19],
    +					[2021,2.8,6.438,-77.25],[2072,3.9,6.551,-43.68],[2081,2.4,6.571,-42.31],[2484,4.4,7.886,-62.96],
    +					[2920,3.7,9.243,53.9],[3092,3.3,9.832,30.86],[3179,2.2,10.127,56.54],[3419,2,10.897,-17.99],
    +					[3760,5.9,12.073,7.3],[3821,3.5,12.276,57.82],[3881,4.5,12.454,41.08],[4427,2.1,14.177,60.72],
    +					[4436,3.9,14.188,38.5],[4577,4.3,14.652,-29.36],[4889,5.5,15.705,31.8],[4906,4.3,15.736,7.89],
    +					[5165,3.3,16.521,-46.72],[5348,3.9,17.096,-55.25],[5364,3.5,17.147,-10.18],[5447,2.1,17.433,35.62],
    +					[5742,4.7,18.437,24.58],[6193,4.7,19.867,27.26],[6537,3.6,21.006,-8.18],[6686,2.7,21.454,60.24],
    +					[6867,3.4,22.091,-43.32],[7007,4.8,22.546,6.14],[7083,3.9,22.813,-49.07],[7097,3.6,22.871,15.35],
    +					[7588,0.5,24.429,-57.24],[7607,3.6,24.498,48.63],[7884,4.5,25.358,5.49],[8102,3.5,26.017,-15.94],
    +					[8198,4.3,26.348,9.16],[8645,3.7,27.865,-10.34],[8796,3.4,28.27,29.58],[8832,3.9,28.383,19.29],
    +					[8833,4.6,28.389,3.19],[8837,4.4,28.411,-46.3],[8886,3.4,28.599,63.67],[8903,2.6,28.66,20.81],
    +					[9007,3.7,28.989,-51.61],[9236,2.9,29.692,-61.57],[9347,4,30.001,-21.08],[9487,3.8,30.512,2.76],
    +					[9598,4,30.859,72.42],[9640,2.1,30.975,42.33],[9884,2,31.793,23.46],[10064,3,32.386,34.99],
    +					[10324,4.4,33.25,8.85],[10559,5.3,33.985,33.36],[10602,3.6,34.127,-51.51],[10826,6.5,34.837,-2.98],
    +					[11001,4.1,35.437,-68.66],[11345,4.9,36.488,-12.29],[11407,4.2,36.746,-47.7],[11484,4.3,37.04,8.46],
    +					[11767,2,37.955,89.26],[11783,4.7,38.022,-15.24],[12093,4.9,38.969,5.59],[12387,4.1,39.871,0.33],
    +					[12390,4.8,39.891,-11.87],[12394,4.1,39.897,-68.27],[12413,4.7,39.95,-42.89],[12484,5.2,40.165,-54.55],
    +					[12486,4.1,40.167,-39.86],[12706,3.5,40.825,3.24],[12770,4.2,41.031,-13.86],[12828,4.3,41.236,10.11],
    +					[12843,4.5,41.276,-18.57],[13147,4.5,42.273,-32.41],[13209,3.6,42.496,27.26],[13254,4.2,42.646,38.32],
    +					[13268,3.8,42.674,55.9],[13531,3.9,43.564,52.76],[13701,3.9,44.107,-8.9],[13847,2.9,44.565,-40.3],
    +					[13954,4.7,44.929,8.91],[14135,2.5,45.57,4.09],[14146,4.1,45.598,-23.62],[14240,5.1,45.903,-59.74],
    +					[14328,2.9,46.199,53.51],[14354,3.3,46.294,38.84],[14576,2.1,47.042,40.96],[14668,3.8,47.374,44.86],
    +					[14879,3.8,48.019,-28.99],[15197,4.8,48.958,-8.82],[15474,3.7,49.879,-21.76],[15510,4.3,49.982,-43.07],
    +					[15863,1.8,51.081,49.86],[15900,3.6,51.203,9.03],[16083,3.7,51.792,9.73],[16228,4.2,52.267,59.94],
    +					[16537,3.7,53.233,-9.46],[16611,4.3,53.447,-21.63],[17358,3,55.731,47.79],[17378,3.5,55.812,-9.76],
    +					[17440,3.8,56.05,-64.81],[17448,3.8,56.08,32.29],[17499,3.7,56.219,24.11],[17529,3.8,56.298,42.58],
    +					[17573,3.9,56.457,24.37],[17651,4.2,56.712,-23.25],[17678,3.3,56.81,-74.24],[17702,2.9,56.871,24.11],
    +					[17797,4.3,57.149,-37.62],[17847,3.6,57.291,24.05],[17874,4.2,57.364,-36.2],[17959,4.6,57.59,71.33],
    +					[18246,2.8,58.533,31.88],[18505,5,59.356,63.07],[18532,2.9,59.463,40.01],[18543,3,59.507,-13.51],
    +					[18597,4.6,59.686,-61.4],[18614,4,59.741,35.79],[18724,3.4,60.17,12.49],[18907,3.9,60.789,5.99],
    +					[19343,4,62.165,47.71],[19747,3.9,63.5,-42.29],[19780,3.3,63.606,-62.47],[19893,4.3,64.007,-51.49],
    +					[19921,4.4,64.121,-59.3],[20042,3.5,64.474,-33.8],[20205,3.6,64.948,15.63],[20455,3.8,65.734,17.54],
    +					[20535,4,66.009,-34.02],[20648,4.3,66.372,17.93],[20885,3.8,67.144,15.96],[20889,3.5,67.154,19.18],
    +					[20894,3.4,67.166,15.87],[21060,5.1,67.709,-44.95],[21281,3.3,68.499,-55.04],[21393,3.8,68.888,-30.56],
    +					[21421,0.9,68.98,16.51],[21444,3.9,69.08,-3.35],[21594,3.9,69.545,-14.3],[21770,4.4,70.14,-41.86],
    +					[21861,5,70.515,-37.14],[21881,4.3,70.561,22.96],[21949,5.5,70.767,-70.93],[22109,4,71.376,-3.25],
    +					[22449,3.2,72.46,6.96],[22509,4.3,72.653,8.9],[22549,3.7,72.802,5.61],[22701,4.4,73.224,-5.45],
    +					[22730,5.3,73.345,2.51],[22783,4.3,73.513,66.34],[22797,3.7,73.563,2.44],[22845,4.6,73.724,10.15],
    +					[23015,2.7,74.248,33.17],[23123,4.5,74.637,1.71],[23416,3,75.492,43.82],[23453,3.7,75.62,41.08],
    +					[23685,3.2,76.365,-22.37],[23767,3.2,76.629,41.23],[23875,2.8,76.962,-5.09],[23972,4.3,77.287,-8.75],
    +					[24244,4.5,78.075,-11.87],[24305,3.3,78.233,-16.21],[24327,4.4,78.308,-12.94],[24436,0.2,78.634,-8.2],
    +					[24608,0.1,79.172,46],[24674,3.6,79.402,-6.84],[24845,4.3,79.894,-13.18],[24873,5.3,79.996,-12.32],
    +					[25110,5.1,80.64,79.23],[120412],[25281,3.4,81.119,-2.4],[25336,1.6,81.283,6.35],
    +					[25428,1.6,81.573,28.61],[25606,2.8,82.061,-20.76],[25859,3.9,82.803,-35.47],[25918,5.2,82.971,-76.34],
    +					[25930,2.3,83.002,-0.3],[25985,2.6,83.183,-17.82],[26069,3.8,83.406,-62.49],[26207,3.4,83.784,9.93],
    +					[26241,2.8,83.858,-5.91],[26311,1.7,84.053,-1.2],[26451,3,84.411,21.14],[26549,3.8,84.687,-2.6],
    +					[26634,2.6,84.912,-34.07],[26727,1.7,85.19,-1.94],[27072,3.6,86.116,-22.45],[27100,4.3,86.193,-65.74],
    +					[27288,3.5,86.739,-14.82],[27321,3.9,86.821,-51.07],[27366,2.1,86.939,-9.67],[27530,4.5,87.457,-56.17],
    +					[27628,3.1,87.74,-35.77],[27654,3.8,87.83,-20.88],[27673,4,87.872,39.15],[27890,4.7,88.525,-63.09],
    +					[27913,4.4,88.596,20.28],[27989,0.5,88.793,7.41],[28103,3.7,89.101,-14.17],[28199,4.4,89.384,-35.28],
    +					[28328,4,89.787,-42.82],[28358,3.7,89.882,54.28],[28360,1.9,89.882,44.95],[28380,2.6,89.93,37.21],
    +					[28614,4.1,90.596,9.65],[28691,5.1,90.864,19.69],[28734,4.2,91.03,23.26],[28910,4.7,91.539,-14.94],
    +					[29038,4.4,91.893,14.77],[29151,5.7,92.241,2.5],[29426,4.5,92.985,14.21],[29651,4,93.714,-6.27],
    +					[29655,3.3,93.719,22.51],[29807,4.4,94.138,-35.14],[30060,4.4,94.906,59.01],[30122,3,95.078,-30.06],
    +					[30277,3.9,95.528,-33.44],[30324,2,95.675,-17.96],[30343,2.9,95.74,22.51],[30419,4.4,95.942,4.59],
    +					[30438,-0.6,95.988,-52.7],[30867,3.8,97.204,-7.03],[30883,4.1,97.241,20.21],[31416,4.5,98.764,-22.96],
    +					[31592,4,99.171,-19.26],[31681,1.9,99.428,16.4],[31685,3.2,99.44,-43.2],[32246,3.1,100.983,25.13],
    +					[32349,-1.4,101.287,-16.72],[32362,3.4,101.322,12.9],[32607,3.2,102.048,-61.94],[32759,3.5,102.46,-32.51],
    +					[32768,2.9,102.484,-50.61],[33018,3.6,103.197,33.96],[33152,3.9,103.533,-24.18],[33160,4.1,103.547,-12.04],
    +					[33165,6.7,103.554,-23.93],[33347,4.4,104.034,-17.05],[33449,4.3,104.319,58.42],[33579,1.5,104.656,-28.97],
    +					[33856,3.5,105.43,-27.93],[33977,3,105.756,-23.83],[34045,4.1,105.94,-15.63],[34088,4,106.027,20.57],
    +					[34444,1.8,107.098,-26.39],[34481,3.8,107.187,-70.5],[34693,4.4,107.785,30.25],[34769,4.2,107.966,-0.49],
    +					[35037,4,108.703,-26.77],[35228,4,109.208,-67.96],[35264,2.7,109.286,-37.1],[35350,3.6,109.523,16.54],
    +					[35550,3.5,110.031,21.98],[35904,2.5,111.024,-29.3],[36046,3.8,111.432,27.8],[36145,4.6,111.679,49.21],
    +					[36188,2.9,111.788,8.29],[36377,3.3,112.308,-43.3],[36850,1.6,113.649,31.89],[36962,4.1,113.981,26.9],
    +					[37229,3.8,114.708,-26.8],[37279,0.4,114.825,5.22],[37447,3.9,115.312,-9.55],[37504,3.9,115.455,-72.61],
    +					[37677,3.9,115.952,-28.95],[37740,3.6,116.112,24.4],[37819,3.6,116.314,-37.97],[37826,1.2,116.329,28.03],
    +					[38146,5.3,117.257,-24.91],[38170,3.3,117.324,-24.86],[38414,3.7,118.054,-40.58],[38827,3.5,119.195,-52.98],
    +					[39429,2.2,120.896,-40],[39757,2.8,121.886,-24.3],[39794,4.3,121.982,-68.62],[39863,4.4,122.149,-2.98],
    +					[39953,1.8,122.383,-47.34],[40526,3.5,124.129,9.19],[40702,4,124.631,-76.92],[40843,5.1,125.016,27.22],
    +					[41037,1.9,125.628,-59.51],[41075,4.3,125.709,43.19],[41307,3.9,126.415,-3.91],[41312,3.8,126.434,-66.14],
    +					[41704,3.4,127.566,60.72],[42313,4.1,129.414,5.7],[42402,4.5,129.689,3.34],[42515,4,130.026,-35.31],
    +					[42536,3.6,130.073,-52.92],[42568,4.3,130.154,-59.76],[42570,3.8,130.157,-46.65],[42799,4.3,130.806,3.4],
    +					[42806,4.7,130.821,21.47],[42828,3.7,130.898,-33.19],[42911,3.9,131.171,18.15],[42913,1.9,131.176,-54.71],
    +					[43023,3.9,131.507,-46.04],[43103,4,131.674,28.76],[43109,3.4,131.694,6.42],[43234,4.3,132.108,5.84],
    +					[43409,4,132.633,-27.71],[43783,3.8,133.762,-60.64],[43813,3.1,133.848,5.95],[44066,4.3,134.622,11.86],
    +					[44127,3.1,134.802,48.04],[44248,4,135.16,41.78],[44382,4,135.612,-66.4],[44471,3.6,135.906,47.16],
    +					[44511,3.8,136.039,-47.1],[44700,4.6,136.632,38.45],[44816,2.2,136.999,-43.43],[45080,3.4,137.742,-58.97],
    +					[45101,4,137.82,-62.32],[45238,1.7,138.3,-69.72],[45336,3.9,138.591,2.31],[45556,2.2,139.273,-59.28],
    +					[45688,3.8,139.711,36.8],[45860,3.1,140.264,34.39],[45941,2.5,140.528,-55.01],[46390,2,141.897,-8.66],
    +					[46509,4.6,142.287,-2.77],[46651,3.6,142.675,-40.47],[46701,3.2,142.805,-57.03],[46733,3.6,142.882,63.06],
    +					[46776,4.5,142.996,-1.18],[46853,3.2,143.214,51.68],[46952,4.5,143.556,36.4],[47431,3.9,144.964,-1.14],
    +					[47508,3.5,145.288,9.89],[47854,3.7,146.312,-62.51],[47908,3,146.463,23.77],[48002,2.9,146.776,-65.07],
    +					[48319,3.8,147.747,59.04],[48356,4.1,147.87,-14.85],[48402,4.5,148.026,54.06],[48455,3.9,148.191,26.01],
    +					[48774,3.5,149.216,-54.57],[48926,5.2,149.718,-35.89],[49583,3.5,151.833,16.76],[49593,4.5,151.857,35.24],
    +					[49641,4.5,151.985,-0.37],[49669,1.4,152.093,11.97],[49841,3.6,152.647,-12.35],[50099,3.3,153.434,-70.04],
    +					[50191,3.9,153.684,-42.12],[50335,3.4,154.173,23.42],[50371,3.4,154.271,-61.33],[50372,3.5,154.274,42.91],
    +					[50583,2,154.993,19.84],[50801,3.1,155.582,41.5],[50954,4,156.099,-74.03],[51069,3.8,156.523,-16.84],
    +					[51172,4.3,156.788,-31.07],[51232,3.8,156.97,-58.74],[51233,4.2,156.971,36.71],[51437,5.1,157.573,-0.64],
    +					[51576,3.3,158.006,-61.69],[51624,3.8,158.203,9.31],[51839,4.1,158.867,-78.61],[51986,3.8,159.326,-48.23],
    +					[52419,2.7,160.739,-64.39],[52468,4.6,160.885,-60.57],[52727,2.7,161.692,-49.42],[52943,3.1,162.406,-16.19],
    +					[53229,3.8,163.328,34.21],[53253,3.8,163.374,-58.85],[53740,4.1,164.944,-18.3],[53910,2.3,165.46,56.38],
    +					[54061,1.8,165.932,61.75],[54463,3.9,167.147,-58.98],[54539,3,167.416,44.5],[54682,4.5,167.915,-22.83],
    +					[54872,2.6,168.527,20.52],[54879,3.3,168.56,15.43],[55203,3.8],[55219,3.5,169.62,33.09],
    +					[55282,3.6,169.835,-14.78],[55425,3.9,170.252,-54.49],[55687,4.8,171.152,-10.86],[55705,4.1,171.221,-17.68],
    +					[56211,3.8,172.851,69.33],[56343,3.5,173.25,-31.86],[56480,4.6,173.69,-54.26],[56561,3.1,173.945,-63.02],
    +					[56633,4.7,174.17,-9.8],[57283,4.7,176.191,-18.35],[57363,3.6,176.402,-66.73],[57380,4,176.465,6.53],
    +					[57399,3.7,176.513,47.78],[57632,2.1,177.265,14.57],[57757,3.6,177.674,1.76],[57936,4.3,178.227,-33.91],
    +					[58001,2.4,178.458,53.69],[58188,5.2,179.004,-17.15],[59196,2.6,182.09,-50.72],[59199,4,182.103,-24.73],
    +					[59316,3,182.531,-22.62],[59449,4,182.913,-52.37],[59747,2.8,183.786,-58.75],[59774,3.3,183.857,57.03],
    +					[59803,2.6,183.952,-17.54],[60000,4.2,184.587,-79.31],[60030,5.9,184.668,-0.79],[60129,3.9,184.976,-0.67],
    +					[60260,3.6,185.34,-60.4],[60718,0.8,186.65,-63.1],[60742,4.3,186.734,28.27],[60823,3.9,187.01,-50.23],
    +					[60965,2.9,187.466,-16.52],[61084,1.6,187.791,-57.11],[61174,4.3,188.018,-16.2],[61199,3.8,188.117,-72.13],
    +					[61281,3.9,188.371,69.79],[61317,4.2,188.436,41.36],[61359,2.6,188.597,-23.4],[61585,2.7,189.296,-69.14],
    +					[61622,3.9,189.426,-48.54],[61932,2.2,190.379,-48.96],[61941,2.7,190.415,-1.45],[62322,3,191.57,-68.11],
    +					[62434,1.3,191.93,-59.69],[62956,1.8,193.507,55.96],[63090,3.4,193.901,3.4],[63125,2.9,194.007,38.32],
    +					[63608,2.9,195.544,10.96],[63613,3.6,195.568,-71.55],[64166,4.9,197.264,-23.12],[64241,4.3,197.497,17.53],
    +					[64394,4.2,197.968,27.88],[64962,3,199.73,-23.17],[65109,2.8,200.149,-36.71],[65378,2.2,200.981,54.93],
    +					[65474,1,201.298,-11.16],[65477,4,201.306,54.99],[65936,3.9,202.761,-39.41],[66249,3.4,203.673,-0.6],
    +					[66657,2.3,204.972,-53.47],[67301,1.9,206.885,49.31],[67459,4,207.369,15.8],[67464,3.4,207.376,-41.69],
    +					[67472,3.5,207.404,-42.47],[67927,2.7,208.671,18.4],[68002,2.5,208.885,-47.29],[68245,3.8,209.568,-42.1],
    +					[68282,3.9,209.67,-44.8],[68520,4.2,210.412,1.54],[68702,0.6,210.956,-60.37],[68756,3.7,211.097,64.38],
    +					[68895,3.3,211.593,-26.68],[68933,2.1,211.671,-36.37],[69427,4.2,213.224,-10.27],[69673,-0.1,213.915,19.18],
    +					[69701,4.1,214.004,-6],[69996,3.5,214.851,-46.06],[70576,4.3,216.545,-45.38],[70638,4.3,216.73,-83.67],
    +					[71053,3.6,217.957,30.37],[71075,3,218.019,38.31],[71352,2.3,218.877,-42.16],[71536,4,219.472,-49.43],
    +					[71681,1.4,219.896,-60.84],[71683,-0,219.902,-60.83],[71795,3.8,220.287,13.73],[71860,2.3,220.482,-47.39],
    +					[71908,3.2,220.627,-64.98],[71957,3.9,220.765,-5.66],[72105,2.4,221.247,27.07],[72220,3.7,221.562,1.89],
    +					[72370,3.8,221.965,-79.04],[72607,2.1,222.676,74.16],[72622,2.8,222.72,-16.04],[73273,2.7,224.633,-43.13],
    +					[73334,3.1,224.79,-42.1],[73555,3.5,225.487,40.39],[73714,3.3,226.018,-25.28],[73807,3.9,226.28,-47.05],
    +					[74376,3.9,227.984,-48.74],[74395,3.4,228.071,-52.1],[74666,3.5,228.876,33.31],[74785,2.6,229.252,-9.38],
    +					[74824,4.1,229.379,-58.8],[74946,2.9,229.727,-68.68],[75097,3,230.182,71.83],[75141,3.2,230.343,-40.65],
    +					[75177,3.6,230.452,-36.26],[75264,3.4,230.67,-44.69],[75323,4.5,230.844,-59.32],[75458,3.3,231.232,58.97],
    +					[75695,3.7,231.957,29.11],[76127,4.1,233.232,31.36],[76267,2.2,233.672,26.71],[76276,3.8,233.701,10.54],
    +					[76297,2.8,233.785,-41.17],[76333,3.9,233.882,-14.79],[76470,3.6,234.256,-28.14],[76552,4.3,234.513,-42.57],
    +					[76600,3.7,234.664,-29.78],[76952,3.8,235.686,26.3],[77055,4.3,236.015,77.79],[77070,2.6,236.067,6.43],
    +					[77233,3.6,236.547,15.42],[77450,4.1,237.185,18.14],[77512,4.6,237.399,26.07],[77516,3.5,237.405,-3.43],
    +					[77622,3.7,237.704,4.48],[77634,4,237.74,-33.63],[77760,4.6,238.169,42.45],[77853,4.1,238.456,-16.73],
    +					[77952,2.8,238.786,-63.43],[78072,3.9,239.113,15.66],[78104,3.9,239.221,-29.21],[78159,4.1,239.397,26.88],
    +					[78265,2.9,239.713,-26.11],[78384,3.4,240.031,-38.4],[78401,2.3,240.083,-22.62],[78493,5,240.361,29.85],
    +					[78527,4,240.472,58.57],[78639,4.7,240.804,-49.23],[78820,2.6,241.359,-19.81],[78933,3.9,241.702,-20.67],
    +					[78970,5.7,241.818,-36.76],[79509,5,243.37,-54.63],[79593,2.7,243.586,-3.69],[79664,3.9,243.859,-63.69],
    +					[79822,5,244.376,75.76],[79882,3.2,244.58,-4.69],[79992,3.9,244.935,46.31],[80000,4,244.96,-50.16],
    +					[80112,2.9,245.297,-25.59],[80170,3.7,245.48,19.15],[80331,2.7,245.998,61.51],[80582,4.5,246.796,-47.55],
    +					[80763,1.1,247.352,-26.43],[80816,2.8,247.555,21.49],[80883,3.8,247.728,1.98],[81065,3.9,248.363,-78.9],
    +					[81126,4.2,248.526,42.44],[81266,2.8,248.971,-28.22],[81377,2.5,249.29,-10.57],[81693,2.8,250.322,31.6],
    +					[81833,3.5,250.724,38.92],[81852,4.2,250.769,-77.52],[82080,4.2,251.493,82.04],[82273,1.9,252.166,-69.03],
    +					[82363,3.8,252.446,-59.04],[82396,2.3,252.541,-34.29],[82514,3,252.968,-38.05],[82545,3.6,253.084,-38.02],
    +					[82671,4.7,253.499,-42.36],[82729,3.6,253.646,-42.36],[83000,3.2,254.417,9.38],[83081,3.1,254.655,-55.99],
    +					[83207,3.9,255.072,30.93],[83895,3.2,257.197,65.71],[84012,2.4,257.595,-15.72],[84143,3.3,258.038,-43.24],
    +					[84345,2.8,258.662,14.39],[84379,3.1,258.758,24.84],[84380,3.2,258.762,36.81],[84606,4.6,259.418,37.29],
    +					[84880,4.3,260.207,-12.85],[84970,3.3,260.502,-25],[85112,4.2,260.921,37.15],[85258,2.8,261.325,-55.53],
    +					[85267,3.3,261.349,-56.38],[85670,2.8,262.608,52.3],[85693,4.4,262.685,26.11],[85696,2.7,262.691,-37.3],
    +					[85727,3.6,262.775,-60.68],[85755,4.8,262.854,-23.96],[85792,2.8,262.96,-49.88],[85822,4.3,263.054,86.59],
    +					[85829,4.9,263.067,55.17],[85927,1.6,263.402,-37.1],[86032,2.1,263.734,12.56],[86228,1.9,264.33,-43],
    +					[86263,3.5,264.397,-15.4],[86414,3.8,264.866,46.01],[86565,4.2,265.354,-12.88],[86670,2.4,265.622,-39.03],
    +					[86742,2.8,265.868,4.57],[86929,3.6,266.433,-64.72],[86974,3.4,266.615,27.72],[87072,4.5,266.89,-27.83],
    +					[87073,3,266.896,-40.13],[87108,3.8,266.973,2.71],[87261,3.2,267.465,-37.04],[87585,3.7,268.382,56.87],
    +					[87808,3.9,269.063,37.25],[87833,2.2,269.152,51.49],[87933,3.7,269.441,29.25],[88048,3.3,269.757,-9.77],
    +					[88192,3.9,270.161,2.93],[88635,3,271.452,-30.42],[88714,3.6,271.658,-50.09],[88771,3.7,271.837,9.56],
    +					[88794,3.8,271.886,28.76],[88866,4.3,272.145,-63.67],[89341,3.8,273.441,-21.06],[89642,3.1,274.407,-36.76],
    +					[89931,2.7,275.249,-29.83],[89937,3.5,275.264,72.73],[89962,3.2,275.328,-2.9],[90098,4.3,275.807,-61.49],
    +					[90139,3.9,275.925,21.77],[90185,1.8,276.043,-34.38],[90422,3.5,276.743,-45.97],[90496,2.8,276.993,-25.42],
    +					[90568,4.1,277.208,-49.07],[90595,4.7,277.299,-14.57],[90887,5.2,278.089,-39.7],[91117,3.9,278.802,-8.24],
    +					[91262,0,279.235,38.78],[91792,4,280.759,-71.43],[91875,5.1,280.946,-38.32],[91971,4.3,281.193,37.61],
    +					[92041,3.2,281.414,-26.99],[92175,4.2,281.794,-4.75],[92202,5.4,281.871,-5.71],[92420,3.5,282.52,33.36],
    +					[92609,4.2,283.054,-62.19],[92791,4.2,283.626,36.9],[92814,5.1,283.68,-15.6],[92855,2,283.816,-26.3],
    +					[92946,4.6,284.055,4.2],[92953,5.3,284.071,-42.71],[92989,5.4,284.169,-37.34],[93015,4.4,284.238,-67.23],
    +					[93085,3.5,284.433,-21.11],[93174,4.8,284.681,-37.11],[93194,3.3,284.736,32.69],[93244,4,284.906,15.07],
    +					[93506,2.6,285.653,-29.88],[93542,4.7,285.779,-42.1],[93683,3.8,286.171,-21.74],[93747,3,286.353,13.86],
    +					[93805,3.4,286.562,-4.88],[93825,4.2,286.605,-37.06],[93864,3.3,286.735,-27.67],[94005,4.6,287.087,-40.5],
    +					[94114,4.1,287.368,-37.9],[94141,2.9,287.441,-21.02],[94160,4.1,287.507,-39.34],[94376,3.1,288.139,67.66],
    +					[94779,3.8,289.276,53.37],[94820,4.9,289.409,-18.95],[95168,3.9,290.418,-17.85],[95241,4,290.66,-44.46],
    +					[95294,4.3,290.805,-44.8],[95347,4,290.972,-40.62],[95501,3.4,291.375,3.11],[95771,4.4,292.176,24.66],
    +					[95853,3.8,292.426,51.73],[95947,3,292.68,27.96],[96406,5.6,294.007,-24.72],[96757,4.4,295.024,18.01],
    +					[96837,4.4,295.262,17.48],[97165,2.9,296.244,45.13],[97278,2.7,296.565,10.61],[97365,3.7,296.847,18.53],
    +					[97433,3.8,297.043,70.27],[97649,0.8,297.696,8.87],[97804,3.9,298.118,1.01],[98032,4.1,298.815,-41.87],
    +					[98036,3.7,298.828,6.41],[98110,3.9,299.077,35.08],[98337,3.5,299.689,19.49],[98412,4.4,299.934,-35.28],
    +					[98495,4,300.148,-72.91],[98543,4.7,300.275,27.75],[98688,4.4,300.665,-27.71],[98920,5.1,301.29,19.99],
    +					[99240,3.5,302.182,-66.18],[99473,3.2,302.826,-0.82],[99675,3.8,303.408,46.74],[99848,4,303.868,47.71],
    +					[100064,3.6,304.514,-12.54],[100345,3,305.253,-14.78],[100453,2.2,305.557,40.26],[100751,1.9,306.412,-56.74],
    +					[101421,4,308.303,11.3],[101769,3.6,309.387,14.6],[101772,3.1,309.392,-47.29],[101958,3.8,309.91,15.91],
    +					[102098,1.3,310.358,45.28],[102281,4.4,310.865,15.07],[102395,3.4,311.24,-66.2],[102422,3.4,311.322,61.84],
    +					[102485,4.1,311.524,-25.27],[102488,2.5,311.553,33.97],[102532,4.3,311.665,16.12],[102618,3.8,311.919,-9.5],
    +					[102831,4.9,312.492,-33.78],[102978,4.1,312.955,-26.92],[103227,3.7,313.703,-58.45],[103413,3.9,314.293,41.17],
    +					[103738,4.7,315.323,-32.26],[104060,3.7,316.233,43.93],[104139,4.1,316.487,-17.23],[104521,4.7,317.585,10.13],
    +					[104732,3.2,318.234,30.23],[104858,4.5,318.62,10.01],[104887,3.7,318.698,38.05],[104987,3.9,318.956,5.25],
    +					[105140,4.7,319.485,-32.17],[105199,2.5,319.645,62.59],[105319,4.4,319.967,-53.45],[105515,4.3,320.562,-16.83],
    +					[105570,5.2,320.723,6.81],[105858,4.2,321.611,-65.37],[105881,3.8,321.667,-22.41],[106032,3.2,322.165,70.56],
    +					[106278,2.9,322.89,-5.57],[106481,4,323.495,45.59],[106985,3.7,325.023,-16.66],[107089,3.7,325.369,-77.39],
    +					[107310,4.5,326.036,28.74],[107315,2.4,326.046,9.88],[107354,4.1,326.161,25.65],[107556,2.9,326.76,-16.13],
    +					[107608,5,326.934,-30.9],[108085,3,328.482,-37.36],[108661,5.4,330.209,-28.45],[109074,3,331.446,-0.32],
    +					[109111,4.5,331.529,-39.54],[109139,4.3,331.609,-13.87],[109176,3.8,331.753,25.35],[109268,1.7,332.058,-46.96],
    +					[109352,5.6,332.307,33.17],[109422,4.9,332.537,-32.55],[109427,3.5,332.55,6.2],[109492,3.4,332.714,58.2],
    +					[109937,4.1,333.992,37.75],[110003,4.2,334.208,-7.78],[110130,2.9,334.625,-60.26],[110395,3.9,335.414,-1.39],
    +					[110538,4.4,335.89,52.23],[110609,4.5,336.129,49.48],[110960,3.6,337.208,-0.02],[110997,4,337.317,-43.5],
    +					[111022,4.3,337.383,47.71],[111104,4.5,337.622,43.12],[111123,4.8,337.662,-10.68],[111169,3.8,337.823,50.28],
    +					[111188,4.3,337.876,-32.35],[111497,4,338.839,-0.12],[111954,4.2,340.164,-27.04],[112029,3.4,340.366,10.83],
    +					[112122,2.1,340.667,-46.88],[112158,2.9,340.751,30.22],[112405,4.1,341.515,-81.38],[112440,4,341.633,23.57],
    +					[112447,4.2,341.673,12.17],[112623,3.5,342.139,-51.32],[112716,4,342.398,-13.59],[112724,3.5,342.42,66.2],
    +					[112748,3.5,342.501,24.6],[112961,3.7,343.154,-7.58],[113136,3.3,343.663,-15.82],[113246,4.2,343.987,-32.54],
    +					[113368,1.2,344.413,-29.62],[113638,4.1,345.22,-52.75],[113726,3.6,345.48,42.33],[113881,2.4,345.944,28.08],
    +					[113963,2.5,346.19,15.21],[114131,4.3,346.72,-43.52],[114341,3.7,347.362,-21.17],[114421,3.9,347.59,-45.25],
    +					[114855,4.2,348.973,-9.09],[114971,3.7,349.291,3.28],[114996,4,349.357,-58.24],[115102,4.4,349.706,-32.53],
    +					[115438,4,350.743,-20.1],[115738,5,351.733,1.26],[115830,4.3,351.992,6.38],[116231,4.4,353.243,-37.82],
    +					[116584,3.8,354.391,46.46],[116727,3.2,354.837,77.63],[116771,4.1,354.988,5.63],[116928,4.5,355.512,1.78],
    +					[118268,4,359.828,6.86]]);
    +
    +	// Data for star names to display (if showstarlabels is set to true) - indexed by Hipparcos number
    +	this.starnames = {};
    +
    +	// Add the stars to the lookup
    +	this.lookup.star = [];
    +	for(i = 0; i < this.stars.length; i++) this.lookup.star.push({'ra':this.stars[i][2],'dec':this.stars[i][3],'label':this.stars[i][0],'mag':this.stars[i][1]});
    +
    +	// Define extra files (JSON/JS)
    +	this.file = {
    +		stars: this.dir+"stars.json",                 // Data for faint stars - 54 kB
    +		lines: this.dir+"lines_latin.json",           // Data for constellation lines - 12 kB
    +		boundaries: this.dir+"boundaries.json",       // Data for constellation boundaries - 20 kB
    +		showers: this.dir+"showers.json",             // Data for meteor showers - 4 kB
    +		galaxy: this.dir+"galaxy.json",               // Data for milky way - 12 kB
    +		planets: this.dir+"virtualsky-planets.js" // Plugin for planet ephemeris - 12kB
    +	};
    +
    +	this.hipparcos = {};          // Define our star catalogue
    +	this.updateClock(new Date()); // Define the 'current' time
    +	this.fullsky = false;         // Are we showing the entire sky?
    +
    +	// Define the colours that we will use
    +	this.colours = {
    +		'normal' : {
    +			'txt' : "rgb(255,255,255)",
    +			'black':"rgb(0,0,0)",
    +			'white':"rgb(255,255,255)",
    +			'grey':"rgb(100,100,100)",
    +			'stars':'rgb(255,255,255)',
    +			'sun':'rgb(255,215,0)',
    +			'moon':'rgb(150,150,150)',
    +			'cardinal':'rgba(163,228,255, 1)',
    +			'constellation':"rgba(180,180,255,0.8)",
    +			'constellationboundary':"rgba(255,255,100,0.6)",
    +			'showers':"rgba(100,255,100,0.8)",
    +			'galaxy':"rgba(100,200,255,0.5)",
    +			'az':"rgba(100,100,255,0.4)",
    +			'eq':"rgba(255,100,100,0.4)",
    +			'ec':'rgba(255,0,0,0.4)',
    +			'gal':'rgba(100,200,255,0.4)',
    +			'meridian':'rgba(25,255,0,0.4)',
    +			'pointers':'rgb(200,200,200)'
    +		},
    +		'negative':{
    +			'txt' : "rgb(0,0,0)",
    +			'black':"rgb(0,0,0)",
    +			'white':"rgb(255,255,255)",
    +			'grey':"rgb(100,100,100)",
    +			'stars':'rgb(0,0,0)',
    +			'sun':'rgb(0,0,0)',
    +			'moon':'rgb(0,0,0)',
    +			'cardinal':'rgba(0,0,0,1)',
    +			'constellation':"rgba(0,0,0,0.8)",
    +			'constellationboundary':"rgba(0,0,0,0.6)",
    +			"showers":"rgba(0,0,0,0.8)",
    +			'galaxy':"rgba(0,0,0,0.5)",
    +			'az':"rgba(0,0,255,0.6)",
    +			'eq':"rgba(255,100,100,0.8)",
    +			'ec':'rgba(255,0,0,0.6)',
    +			'gal':'rgba(100,200,255,0.8)',
    +			'meridian':'rgba(0,255,0,0.6)'
    +		}
    +	};
    +
    +	// Keep a copy of the inputs
    +	this.input = input;
    +
    +	// Overwrite our defaults with input values
    +	this.init(input);
    +
    +	// Country codes at http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
    +	this.language = (typeof this.q.lang==="string") ? this.q.lang : (typeof this.setlang==="string" ? this.setlang : (navigator) ? (navigator.userLanguage||navigator.systemLanguage||navigator.language||browser.language) : "");
    +	var fromqs = (typeof this.q.lang==="string" || typeof this.setlang==="string");
    +	this.langs = {
    +		'ar': { "language": {"name": "&#1575;&#1604;&#1593;&#1585;&#1576;&#1610;&#1577;","alignment": "right" } },
    +		'cs': { "language": {"name": "&#268;e&#353;tina","alignment": "left" } },
    +		'de': { "language": {"name": "Deutsch","alignment": "left" } },
    +		'en': { "language": {"name": "English","alignment": "left" } },
    +		'es': { "language": {"name": "Espa&#241;ol","alignment": "left" } },
    +		'fr': { "language": {"name": "Fran&#231;ais","alignment": "left" } },
    +		'gl': { "language": {"name": "Galego","alignment": "left" } },
    +		'it': { "language": {"name": "Italiano","alignment": "left" } },
    +		'nl': { "language": {"name": "Nederlands","alignment": "left" } },
    +		'pl': { "language": {"name": "Polski","alignment": "left" } },
    +		'pt': { "language": {"name": "Portugu&#234;s","alignment": "left" } },
    +	}; // The contents of the language will be loaded from the JSON language file
    +	this.lang = this.langs.en;	// default
    +
    +	if(typeof this.polartype=="undefined") this.selectProjection('polar');	// Set the default projection
    +
    +	// Update the colours
    +	this.updateColours();
    +
    +	// Load the language file
    +	this.loadLanguage(this.language,'',fromqs);
    +
    +	// Define some VirtualSky styles
    +	var v,a,b,r,s,p,k,c,bs;
    +	v = '.virtualsky';
    +	a = '#f0f0f0';
    +	b = '#fcfcfc';
    +	k = 'background';
    +	c = k+'-color';
    +	p = 'padding';
    +	this.padding = 4;
    +	bs = 'box-shadow:0px 0px 20px rgba(255,255,255,0.5);';
    +	function br(i){ return 'border-radius:'+i+';-moz-border-radius:'+i+';-webkit-border-radius:'+i+';';}
    +	r = br('0em');
    +	s = br('3px');
    +	S('head').append('<style type="text/css">'+
    +		v+'_help { '+p+':10px;'+c+':white;'+r+'} '+
    +		v+'_help ul { list-style:none;margin:0px;'+p+':0px; } '+
    +		v+'_infobox { '+c+':'+a+';color:black;'+p+':5px;'+r+bs+'} '+
    +		v+'_infobox img {} '+
    +		v+'_infocredit {color:white;float:left;font-size:0.8em;'+p+':5px;position:absolute;} '+
    +		// ALLSKY ADDITION: added "color:black" otherwise it was hard to see
    +		v+'form { color:black;position:absolute;z-index:20;display:block;overflow:hidden;'+c+':#ddd;'+p+':10px;'+bs+r+' } '+
    +		v+'_dismiss { float:right;'+p+': 0 5px 0 5px;margin:0px;font-weight:bold;cursor:pointer;color:black;margin-right:-5px;margin-top:-5px; } '+
    +		v+'form input,'+v+'form .divider { display:inline-block;font-size:1em;text-align:center;margin-right:2px; } '+v+'form .divider { margin-top: 5px; '+p+': 2px;} '+
    +		v+'button { '+k+':#e9e9e9; width: 1.5em; line-height: 1.5em; color: '+a+'; cursor: pointer; display: block; padding: 0px; text-align: center; color: #000000; font-size: 1em; } '+
    +		v+'_help_key:active{ '+k+':#e9e9e9; } '+
    +		v+'_help_key:hover{ border-color: #b0b0b0; } '+
    +		v+'_help_key { cursor:pointer;display:inline-block;text-align:center;'+
    +			k+':'+a+';'+k+':-moz-linear-gradient(top,'+a+','+b+');'+
    +			k+':-webkit-gradient(linear,center top,center bottom,from('+a+'),to('+b+'));'+
    +			s+'-webkit-'+k+'-clip:'+p+'-box;-moz-'+k+'-clip:'+p+';'+k+'-clip:'+
    +			p+'-box;color:#303030;border:1px solid #e0e0e0;border-bottom-width:2px;white-space:nowrap;font-family:monospace'+
    +			';'+p+':1px 6px;font-size:1.1em;}</style>');
    +
    +	this.pointers = []; // Define an empty list of pointers/markers
    +
    +	// Internal variables
    +	this.dragging = false;
    +	this.x = "";
    +	this.y = "";
    +	this.theta = 0;
    +	this.skygrad = null;
    +	this.infobox = "virtualsky_infobox";
    +	this.container = '';
    +	this.times = this.astronomicalTimes();
    +	if(this.id) this.createSky();
    +
    +	// Find out where the Sun and Moon are
    +	p = this.moonPos(this.times.JD);
    +	this.moon = p.moon;
    +	this.sun = p.sun;
    +
    +	if(this.islive) {
    +		// ALLSKY COMMENT: "interval" needs to be global.
    +		if (typeof interval !== undefined) clearInterval(interval);
    +		interval = window.setInterval(function(sky){ sky.setClock('now', "setInterval"); },5000,this);	// ECC: s/1/5/
    +	}
    +
    +	return this;
    +}
    +
    +VirtualSky.prototype.init = function(d){
    +	if(!d) return this;
    +	var q = location.search;
    +	var key,val,i;
    +
    +	if(q && q != '#'){
    +		var bits = q.replace(/^\?|\&$/g,'').split('&'); // remove the leading ? and trailing &
    +		for(i = 0; i < bits.length ; i++){
    +			key = bits[i].split('=')[0];
    +			val = bits[i].split('=')[1];
    +			// convert floats
    +			if(/^-?[0-9.]+$/.test(val)) val = parseFloat(val);
    +			if(val == "true") val = true;
    +			if(val == "false") val = false;
    +			// apply only first key occurency
    +			if(d[key]===undefined) d[key] = val;
    +		}
    +	}
    +	var n = "number";
    +	var s = "string";
    +	var b = "boolean";
    +	var o = "object";
    +	var f = "function";
    +
    +
    +	// Overwrite defaults with variables passed to the function
    +	// directly mapped variables
    +	var pairs = {
    +		id: s,
    +		gradient: b,
    +		cardinalpoints: b,
    +		negative: b,
    +		meteorshowers: b,
    +		showstars: b,
    +		scalestars: n,
    +		showstarlabels: b,
    +		starnames: o,
    +		showplanets: b,
    +		showplanetlabels: b,
    +		showorbits: b,
    +		showgalaxy: b,
    +		showdate: b,
    +		showposition: b,
    +		keyboard: b,
    +		mouse: b,
    +		ground: b,
    +		ecliptic: b,
    +		meridian: b,
    +		magnitude: n,
    +		clock: o,
    +		background: s,
    +		color: s,
    +		fov: n,
    +		objects: s,
    +		base: s,
    +		fullscreen: b,
    +		credit: b,
    +		transparent: b,
    +		plugins: o,
    +		lang: s
    +	};
    +	for(key in pairs)
    +		if(is(d[key], pairs[key]))
    +			this[key] = d[key];
    +
    +	// Undirectly paired values
    +	if(is(d.opacity,n)) this.opacity = d.opacity;	// ALLSKY ADDED
    +	if(is(d.projection,s)) this.selectProjection(d.projection);
    +	if(is(d.constellations,b)) this.constellation.lines = d.constellations;
    +	if(is(d.constellationboundaries,b)) this.constellation.boundaries = d.constellationboundaries;
    +	if(is(d.constellationlabels,b)) this.constellation.labels = d.constellationlabels;
    +	if(is(d.constellationwidth,n)) this.constellation.lineWidth = d.constellationwidth;
    +	if(is(d.constellationboundarieswidth,n)) this.constellation.boundaryWidth = d.constellationboundarieswidth;
    +	if(is(d.gridlines_az,b)) this.grid.az = d.gridlines_az;
    +	if(is(d.gridlines_eq,b)) this.grid.eq = d.gridlines_eq;
    +	if(is(d.gridlines_gal,b)) this.grid.gal = d.gridlines_gal;
    +	if(is(d.gridstep,n)) this.grid.step = d.gridstep;
    +	if(is(d.gridlineswidth,n)) this.grid.lineWidth = d.gridlineswidth;
    +	if(is(d.galaxywidth,n)) this.gal.lineWidth = d.galaxywidth;
    +	if(is(d.longitude,n)) this.setLongitude(d.longitude);
    +	if(is(d.latitude,n)) this.setLatitude(d.latitude);
    +	if(is(d.clock,s)) this.updateClock(new Date(d.clock.replace(/%20/g,' ')));
    +	if(is(d.az,n)) this.az_off = (d.az%360)-180;
    +	if(is(d.ra,n)) this.setRA(d.ra);
    +	if(is(d.dec,n)) this.setDec(d.dec);
    +	if(is(d.planets,s)) this.file.planets = d.planets;
    +	if(is(d.planets,o)) this.planets = d.planets;
    +	if(is(d.lines,s)) this.file.lines = d.lines;
    +	if(is(d.lines,o)) this.lines = d.lines;
    +	if(is(d.boundaries,s)) this.file.boundaries = d.boundaries;
    +	if(is(d.boundaries,o)) this.boundaries = d.boundaries;
    +	if(is(d.width,n)) this.wide = d.width;
    +	if(is(d.height,n)) this.tall = d.height;
    +	if(is(d.live,b)) this.islive = d.live;
    +	if(is(d.lang,s) && d.lang.length==2) this.language = d.lang;
    +	if(is(d.fontfamily,s)) this.fntfam = d.fontfamily.replace(/%20/g,' ');
    +	if(is(d.fontsize,s)) this.fntsze = d.fontsize;
    +	if(is(d.cardinalpoints_fontsize,s)) this.cardinalpoints_fntsze = d.cardinalpoints_fontsize;		// ALLSKY ADDED
    +	if(is(d.lang,s)) this.setlang = d.lang;
    +	if(is(d.callback,o)){
    +		if(is(d.callback.geo,f)) this.callback.geo = d.callback.geo;
    +		if(is(d.callback.click,f)) this.callback.click = d.callback.click;
    +		if(is(d.callback.mouseenter,f)) this.callback.mouseenter = d.callback.mouseenter;
    +		if(is(d.callback.mouseout,f)) this.callback.mouseout = d.callback.mouseout;
    +		if(is(d.callback.cursor,f)) this.callback.cursor = d.callback.cursor;
    +		if(is(d.callback.contextmenu,f)) this.callback.contextmenu = d.callback.contextmenu;
    +		if(is(d.callback.draw,f)) this.callback.draw = d.callback.draw;
    +	}
    +
    +	// ALLSKY ADDED: Replace default colors with ones the user specified.
    +	if (d.colours) {
    +		var c = d.colours.normal;
    +		if (c) {
    +			for(key in c) {
    +//x console.log(">> key=" + key + ", value=" + c[key]);
    +				this.colours.normal[key] = c[key];
    +			}
    +		}
    +	
    +		c = d.colours.negative;
    +		if (c) {
    +			for(key in c) {
    +				this.colours.negative[key] = c[key];
    +			}
    +		}
    +	}
    +
    +	return this;
    +};
    +
    +// Load the specified language
    +// If it fails and this was the long variation of the language (e.g. "en-gb" or "zh-yue"), try the short version (e.g. "en" or "zh")
    +VirtualSky.prototype.loadLanguage = function(l,fn,fromquerystring){
    +	l = l || this.language;
    +	var lang = "";
    +	if(this.langs[l]) lang = l;
    +	if(!lang){
    +		// Try loading a short version of the language code
    +		l = (l.indexOf('-') > 0 ? l.substring(0,l.indexOf('-')) : l.substring(0,2));
    +		if(fromquerystring){
    +			// If it was set in the query string we try it
    +			lang = l;
    +		}else{
    +			// If it was just from the browser settings, we'll limit to known translations
    +			if(this.langs[l]) lang = l;
    +		}
    +	}
    +	l = lang;
    +	if(!l) l = "en";	// Use English as a default if we haven't got a language here
    +	var url = this.langurl.replace('%LANG%',l);
    +	this.loadJSON(
    +		url,
    +		function(data){
    +			this.langcode = l;
    +			this.langs[l] = data;
    +			this.langs[l].loaded = true;
    +
    +			// Update any starnames
    +			if(data.starnames){
    +				for(var n in data.starnames){
    +					if(data.starnames[n]) this.starnames[n] = data.starnames[n];
    +				}
    +			}
    +
    +			this.changeLanguage(l);
    +			if(typeof fn==="function") fn.call(this);
    +		},
    +		function(data){ },
    +		function(e){
    +			// If we tried to load the short version of the language and it failed,
    +			// default to English (unless we were trying to get English in which
    +			// case something is very wrong).
    +			if(l!="en") this.loadLanguage('en',fn);
    +		}
    +	);
    +	return this;
    +};
    +// Change the active language
    +var priorLangcode = "";		// ALLSKY ADDED: keep track of last language to avoid displaying if it hasn't changed.
    +VirtualSky.prototype.changeLanguage = function(code,fn){
    +	if(this.langs[code]){
    +		if(!this.langs[code].loaded) this.loadLanguage(code,fn);
    +		else {
    +			this.lang = this.langs[code];
    +			this.langcode = code;
    +			if (this.langcode !== priorLangcode) {	// ALLSKY CHANGE: only display if language changed
    +				priorLangcode = this.langcode;
    +				this.drawImmediate(undefined, "changeLanguage, langcode="+this.langcode + ", code="+code);
    +				if(typeof fn==="function") fn.call(this);
    +			}
    +		}
    +		return this;
    +	}
    +	this.lang = this.langs.en;
    +	return this;
    +};
    +VirtualSky.prototype.htmlDecode = function(input){
    +	if(!input) return "";
    +	var e = document.createElement('div');
    +	e.innerHTML = input;
    +	return e.childNodes[0].nodeValue;
    +};
    +VirtualSky.prototype.getPhrase = function(key,key2){
    +	if(key===undefined) return undefined;
    +	if(key==="constellations"){
    +		if(key2 && is(this.lang.constellations[key2],"string"))
    +			return this.htmlDecode(this.lang.constellations[key2]);
    +	}else if(key==="planets"){
    +		if(this.lang.planets && this.lang.planets[key2]) return this.htmlDecode(this.lang.planets[key2]);
    +		else return this.htmlDecode(this.lang[key2]);
    +	}else return this.htmlDecode(this.lang[key]) || this.htmlDecode(this.langs.en[key]) || "";
    +};
    +VirtualSky.prototype.resize = function(w,h){
    +	if(!this.canvas) return;
    +	if(!w || !h){
    +		if(this.fullscreen){
    +			this.canvas.css({'width':0,'height':0});
    +			w = window.innerWidth;
    +			h = window.innerHeight;
    +			this.canvas.css({'width':w+'px','height':h+'px'});
    +		}else{
    +			// ALLSKY CHANGED:
    +			// This resizing never worked, so it's now done in controller.js.
    +			// Perhaps someone better at javascript / css can get this working.
    +			// The global "wasDiff" is set in controller.js and tells us if the last window resize
    +			// changed the size of the overlay or its containers.
    +			// Because controller.js does the resize of the overlay, do NOT do it below.
    +			// However, we still want text and other things resized.
    +			if (! wasDiff) return;		// There was no difference.
    +
    +			// We have to zap the width of the canvas to let it take the width of the container
    +			this.canvas.css({'width':0,'height':0});
    +			// this.container == "#starmap".  The outerWidth never changes so it doesn't work.
    +/*
    +			w = this.container.outerWidth();
    +			h = this.container.outerHeight();
    + 			this.canvas.css({'width':w+'px','height':h+'px'});
    +*/
    +		}
    +	}else{
    +		// Set the container size
    +		this.container.css({'width':w+'px','height':h+'px'});
    +	}
    +	if(w == this.wide && h == this.tall) return;
    +	this.setWH(w,h);
    +	this.positionCredit();
    +	this.updateSkyGradient();
    +	this.drawImmediate(undefined, "resize");
    +	this.container.css({'font-size':this.fontsize()+'px'});
    +	this.trigger('resize',{vs:this});
    +};
    +VirtualSky.prototype.setWH = function(w,h){
    +	if(!w || !h) return;
    +	this.c.width = w;
    +	this.c.height = h;
    +	this.wide = w;
    +// console.log(">>>> setWH(" + w + ", " + h + ")");
    +	this.tall = h;
    +	this.changeFOV();
    +	// DEPRECATED // Bug fix for IE 8 which sets a width of zero to a div within the <canvas>
    +	// DEPRECATED if(this.ie && S.browser.version == 8) $('#'+this.idinner).find('div').css({'width':w,'height':h});
    +	this.canvas.css({'width':w+'px','height':h+'px'});
    +};
    +VirtualSky.prototype.changeFOV = function(delta){
    +	var fov = this.fov;
    +	if(delta > 0) fov /= 1.05;
    +	else if(delta < 0) fov *= 1.05;
    +	return this.setFOV(fov);
    +};
    +VirtualSky.prototype.setFOV = function(fov){
    +	if(fov > 60 || typeof fov!=="number") this.fov = 60;
    +	else if(fov < 1) this.fov = 1;
    +	else this.fov = fov;
    +	this.maxangle = this.d2r*this.fov*Math.max(this.wide,this.tall)/this.tall;
    +	this.maxangle = Math.min(this.maxangle,Math.PI/2);
    +	return this;
    +};
    +// Some pseudo-jQuery
    +VirtualSky.prototype.hide = function(){ this.container.hide(); return this; };
    +VirtualSky.prototype.show = function(){ this.container.show(); return this; };
    +VirtualSky.prototype.toggle = function(){ this.container.toggle(); return this; };
    +// Our stars are stored in decimal degrees so we will convert them here
    +VirtualSky.prototype.convertStarsToRadians = function(stars){
    +	for(var i = 0; i < stars.length; i++){
    +		stars[i][2] *= this.d2r;
    +		stars[i][3] *= this.d2r;
    +	}
    +	return stars;
    +};
    +VirtualSky.prototype.load = function(t,file,fn){
    +	return this.loadJSON(file,function(data){
    +		if(t=="stars"){
    +			this.starsdeep = true;
    +			this.stars = this.stars.concat(this.convertStarsToRadians(data.stars));
    +			// Add the stars to the lookup
    +			this.lookup.star = [];
    +			for(i = 0; i < this.stars.length; i++) this.lookup.star.push({'ra':this.stars[i][2],'dec':this.stars[i][3],'label':this.stars[i][0],'mag':this.stars[i][1]});
    +		}
    +		else{ this[t] = data[t]; }
    +		this.draw("load");
    +		this.trigger("loaded"+(t.charAt(0).toUpperCase() + t.slice(1)),{data:data});
    +	},fn);
    +};
    +VirtualSky.prototype.loadJSON = function(file,callback,complete,error){
    +	if(typeof file!=="string") return this;
    +	var dt = file.match(/\.json$/i) ? "json" : "script";
    +	if(dt=="script"){
    +		// If we are loading an external script we need to make sure we initiate
    +		// it first. To do that we will re-write the callback that was provided.
    +		var tmp = callback;
    +		callback = function(data){
    +			// Initialize any plugins
    +			for (var i = 0; i < this.plugins.length; ++i){
    +				if(typeof this.plugins[i].init=="function") this.plugins[i].init.call(this);
    +			}
    +			tmp.call(this,data);
    +		};
    +	}
    +	var config = {
    +		dataType: dt,
    +		"this": this,
    +		success: callback,
    +		complete: complete || function(){},
    +		error: error || function(){}
    +	};
    +	if(dt=="json") config.jsonp = 'onJSONPLoad';
    +	if(dt=="json") config.cache = true;	// ALLSKY added
    +	if(dt=="script") config.cache = true;	// Use a cached version
    +	S(document).ajax(this.base+file,config);
    +	return this;
    +};
    +
    +VirtualSky.prototype.debug = function(msg){
    +	if(S('#debug').length==1){
    +		var id = 'debug-'+(new Date()).valueOf();
    +		S('#debug').append('<span id="'+id+'">'+msg+'</span> ');
    +		setTimeout(function(){ S('#'+id).remove(); },1000);
    +	}
    +	return this;
    +};
    +
    +VirtualSky.prototype.checkLoaded = function(){
    +
    +	// Get the planet data
    +	if(!this.planets && this.showplanets) this.load('planets',this.file.planets);
    +
    +	// Get the constellation line data
    +	if(!this.lines && this.constellation.lines) this.load('lines',this.file.lines);
    +
    +	// Get the constellation line data
    +	if(!this.boundaries && this.constellation.boundaries) this.load('boundaries',this.file.boundaries);
    +
    +	// Get the meteor showers
    +	if(!this.showers && this.meteorshowers) this.load('showers',this.file.showers);
    +
    +	// Get the Milky Way
    +	if(!this.galaxy && this.showgalaxy) this.load('galaxy',this.file.galaxy);
    +
    +	return this;
    +};
    +
    +VirtualSky.prototype.createSky = function(){
    +	this.container = S('#'+this.id);
    +	this.times = this.astronomicalTimes();
    +
    +	if(this.q.debug) S('body').append('<div style="position: absolute;bottom:0px;right:0px;padding: 0.25em 0.5em;background-color:white;color:black;max-width: 50%;" id="debug"></div>');
    +	if(this.fntfam) this.container.css({'font-family':this.fntfam});
    +	if(this.fntsze) this.container.css({'font-size':this.fntsze});
    +
    +	if(this.container.length == 0){
    +		// No appropriate container exists. So we'll make one.
    +		S('body').append('<div id="'+this.id+'"></div>');
    +		this.container = S('#'+this.id);
    +	}
    +	this.container.css('position','relative');
    +	var _obj = this;
    +	window.onresize = function(){ _obj.resize(); };
    +
    +	this.checkLoaded();
    +
    +	// Get the faint star data
    +	this.changeMagnitude(0);
    +
    +	var o;
    +
    +	// Add named objects to the display
    +	if(this.objects){
    +		// To stop lookUp being hammered, we'll only lookup a maximum of 5 objects
    +		// If you need more objects (e.g. for the Messier catalogue) build a JSON
    +		// file containing all the results one time only.
    +		var ob = this.objects.split(';');
    +
    +		// Build the array of JSON requests
    +		// ALLSKY CHANGE: Stuart's lookup no longer works.
    +		// ALLSKY CHANGE: for(o = 0; o < ob.length ; o++) ob[o] = ((ob[o].search(/\.json$/) >= 0) ? {'url':ob[o], 'src':'file', 'type':'json' } : {'url': 'https://www.strudel.org.uk/lookUP/json/?name='+ob[o],'src':'lookup','type':'jsonp'});
    +		for(o = 0; o < ob.length ; o++) ob[o] = ((ob[o].search(/\.json$/) >= 0) ? {'url':ob[o], 'src':'file', 'type':'json' } : {'src':''});
    +
    +		// Loop over the requests
    +		var lookups = 0;
    +		var ok = true;
    +		for(o = 0; o < ob.length ; o++){
    +			if(ob[o].src == "") continue;	// ALLSKY CHANGE: was a lookup
    +			if(ob[o].src == "lookup") lookups++;
    +			if(lookups > 5) ok = false;
    +			if(ok || ob[o].src != "lookup"){
    +				S(document).ajax(ob[o].url, { dataType: ob[o].type, "this": this, success: function(data){
    +					// If we don't have a length property, we only have one result so make it an array
    +					if(typeof data.length === "undefined") data = [data];
    +					// Loop over the array of objects
    +					for(var i = 0; i < data.length ; i++){
    +						// The object needs an RA and Declination
    +						if(data[i] && data[i].dec && data[i].ra){
    +							this.addPointer({
    +								ra: data[i].ra.decimal,
    +								dec: data[i].dec.decimal,
    +								label: data[i].target.name,
    +								colour: this.col.pointers
    +							});
    +						}
    +					}
    +					// ALLSKY: moved this out of the "for" loop above.
    +					// Update the sky with all the points we've added
    +					this.draw("objects");
    +				}});
    +			}
    +		}
    +	}
    +
    +	// If the Javascript function has been passed a width/height
    +	// those take precedence over the CSS-set values
    +	if(this.wide > 0) this.container.css({'width':this.wide+'px'});
    +	else this.wide = this.container.width();
    +	if(this.tall > 0) this.container.css({'height':this.tall+'px'});
    +	this.tall = this.container.height()-0;
    +
    +	// Add a <canvas> to it with the original ID
    +	this.idinner = this.id+'_inner';
    +	this.container.html('<canvas id="'+this.idinner+'" style="display:block;"></canvas>');
    +	this.canvas = S('#'+this.idinner);
    +	this.canvas.css({'opacity':this.opacity});	// ALLSKY ADDED
    +	this.c = document.getElementById(this.idinner);
    +	// For excanvas we need to initialise the newly created <canvas>
    +	if(this.excanvas)
    +		this.c = G_vmlCanvasManager.initElement(this.c);
    +
    +	if(this.c && this.c.getContext){
    +		this.setWH(this.wide,this.tall);
    +		var ctx = this.ctx = this.c.getContext('2d');
    +		ctx.clearRect(0,0,this.wide,this.tall);
    +		ctx.beginPath();
    +		var fs = this.fontsize();
    +		ctx.font = fs+"px Helvetica";
    +		ctx.fillStyle = 'rgb(0,0,0)';
    +		ctx.lineWidth = 1.5;
    +		var loading = 'Loading sky...';
    +		ctx.fillText(loading,(ctx.wide-ctx.measureText(loading).width)/2,(this.tall-fs)/2);
    +		ctx.fill();
    +
    +		// ALLSKY 0.7.3 PR added touchClickHandler and deleted contextManuHandler
    +		var touchClickHandler = (!this.callback.contextmenu && !this.callback.click) ? undefined : {
    +			// Indicate an immobile press is occuring. It will lead to context menu or click depending on its duration
    +			clickActive: false,
    +			clickDone:  false,
    +			// Timer that differentiate between long/short press
    +			longPressTimer: undefined,
    +
    +			clickStart: function(e){
    +				e.originalEvent.preventDefault();
    +				touchClickHandler.clickCancel();
    +				touchClickHandler.clickActive = true;
    +				touchClickHandler.clickDone = false;
    +				touchClickHandler.initialTouchEvent = e;
    +				if (this.callback.contextmenu) {
    +					touchClickHandler.longPressTimer =  window.setTimeout(function() {
    +						touchClickHandler.clickActive = false;
    +						touchClickHandler.longPressTimer = undefined;
    +						this.dragging = false;
    +						this.x = "";
    +						this.y = "";
    +						this.theta = "";
    +
    +						if (this.callback.contextmenu) {
    +							touchClickHandler.clickDone = true;
    +							this.callback.contextmenu.call(e.data.sky, e);
    +						}
    +					}.bind(this), 400 /** 400ms for long press */);
    +				}
    +			}.bind(this),
    +
    +			clickEnd: function(e) {
    +				if (touchClickHandler.clickActive) {
    +					var initialTouchEvent = touchClickHandler.initialTouchEvent;
    +					touchClickHandler.clickCancel();
    +
    +					if(e.data.sky.callback.click){
    +						touchClickHandler.clickDone = true;
    +						e.data.sky.callback.click.call(initialTouchEvent.data.sky, initialTouchEvent);
    +					}
    +				}
    +			}.bind(this),
    +
    +			clickCancel: function(){
    +				touchClickHandler.clickActive = false;
    +				touchClickHandler.initialTouchEvent = undefined;
    +				if (touchClickHandler.longPressTimer !== undefined) {
    +					window.clearTimeout(touchClickHandler.longPressTimer);
    +					touchClickHandler.longPressTimer = undefined;
    +				}
    +				return !touchClickHandler.clickDone;
    +			}.bind(this),
    +		};
    +
    +		function getXYProperties(e,sky){
    +			e.matched = sky.whichPointer(e.x,e.y);
    +			var skyPos = sky.xy2radec(e.x,e.y);
    +			if(skyPos){
    +				e.ra = skyPos.ra / sky.d2r;
    +				e.dec = skyPos.dec / sky.d2r;
    +			}
    +			return e;
    +		}
    +		function getXY(sky,o,el,e){
    +			e.x = o.pageX - el.offset().left - window.scrollX;
    +			e.y = o.pageY - el.offset().top - window.scrollY;
    +			return getXYProperties(e,sky);
    +		}
    +		function getTouchXY(sky,o,el,e) {	// 0.7.3 PR
    +			e.x = o.touches[0].pageX - el.offset().left - window.scrollX;
    +			e.y = o.touches[0].pageY - el.offset().top - window.scrollY;
    +			return getXYProperties(e,sky);
    +		}
    +
    +		S("#"+this.idinner).on('click',{sky:this},function(e){
    +			e.data.sky.debug('click');
    +			var p = getXY(e.data.sky,e.originalEvent,this,e);
    +			if(p.matched) e.data.sky.toggleInfoBox(p.matched);
    +			if(p.matched >= 0) S(e.data.sky.canvas).css({cursor:'pointer'});
    +			if(e.data.sky.callback.click) e.data.sky.callback.click.call(e.data.sky,getXY(e.data.sky,e.originalEvent,this,e));
    +		}).on('contextmenu',{sky:this},function(e){
    +			if(e.data.sky.callback.contextmenu){
    +				e.preventDefault();
    +				e.data.sky.callback.contextmenu.call(e.data.sky,getXY(e.data.sky,e.originalEvent,this,e));
    +			}
    +		}).on('dblclick',{sky:this},function(e){
    +			e.data.sky.debug('dblclick');
    +			e.data.sky.toggleFullScreen();
    +		}).on('mousemove',{sky:this},function(e){
    +			e.preventDefault();
    +			e.data.sky.debug('mousemove');
    +			var s = e.data.sky;
    +			var x = e.originalEvent.layerX;
    +			var y = e.originalEvent.layerY;
    +			var theta,f,dr,matched;
    +			if(s.mouse) s.canvas.css({cursor:'move'});
    +			if(s.dragging && s.mouse){
    +				if(s.polartype){
    +					theta = Math.atan2(y-s.tall/2,x-s.wide/2);
    +					if(!s.theta) s.theta = theta;
    +					s.az_off -= (s.theta-theta)*s.r2d;
    +					s.theta = theta;
    +				}else if(s.projection.id=="gnomic"){
    +					f = 0.0015*(s.fov*s.d2r);
    +					dr = 0;
    +					if(typeof s.x=="number") dr = Math.min(Math.abs(s.x-x)*f/(Math.cos(s.dc_off)),Math.PI/36);
    +					if(typeof s.y=="number") s.dc_off -= (s.y-y)*f;
    +					s.ra_off -= (s.x-x > 0 ? 1 : -1)*dr;
    +					s.dc_off = inrangeEl(s.dc_off);
    +				}else{
    +					if(typeof s.x=="number") s.az_off += (s.x-x)/4;
    +				}
    +				s.az_off = s.az_off%360;
    +				s.x = x;
    +				s.y = y;
    +				s.draw("mousemove");
    +				s.canvas.css({cursor:'-moz-grabbing'});
    +			}else{
    +				matched = s.whichPointer(x,y);
    +				s.toggleInfoBox(matched);
    +			}
    +			if(typeof s.callback.cursor=="function"){
    +				var p = getXY(e.data.sky,e.originalEvent,this,e);
    +				e.data.sky.callback.cursor.call(this,p);
    +			}
    +		}).on('mousedown',{sky:this},function(e){
    +			if(e.originalEvent.buttons === 1){
    +				e.data.sky.debug('mousedown');
    +				e.data.sky.dragging = true;
    +			}else if(e.originalEvent.buttons === 2){
    +				this.trigger('contextmenu',e);
    +			}
    +		}).on('mouseup',{sky:this},function(e){
    +			e.data.sky.debug('mouseup');
    +			var s = e.data.sky;
    +			s.dragging = false;
    +			s.x = "";
    +			s.y = "";
    +			s.theta = "";
    +		}).on('mouseout',{sky:this},function(e){
    +			e.data.sky.debug('mouseout');
    +			var s = e.data.sky;
    +			s.dragging = false;
    +			s.mouseover = false;
    +			s.x = "";
    +			s.y = "";
    +			if(typeof s.callback.mouseout=="function") s.callback.mouseout.call(s);
    +		}).on('mouseenter',{sky:this},function(e){
    +			e.data.sky.debug('mouseenter');
    +			var s = e.data.sky;
    +			s.mouseover = true;
    +			if(typeof s.callback.mouseenter=="function") s.callback.mouseenter.call(s);
    +		}).on('touchmove',{sky:this},function(e){
    +			e.preventDefault();
    +			if(touchClickHandler) {
    +				if (!touchClickHandler.clickCancel()) {
    +					return;
    +				}
    +			}
    +			var s = e.data.sky;
    +			var x = e.originalEvent.touches[0].pageX;
    +			var y = e.originalEvent.touches[0].pageY;
    +			e.data.sky.debug('touchmove '+x+','+y+' '+s.x+','+s.y+'');
    +			var theta,f,dr;
    +			if(s.mouse && s.dragging){		// ALLSKY fix: added "s.mouse &&"
    +				if(s.polartype){
    +					theta = Math.atan2(y-s.tall/2,x-s.wide/2);
    +					if(!s.theta) s.theta = theta;
    +					s.az_off -= (s.theta-theta)*s.r2d;
    +					s.theta = theta;
    +				}else if(s.projection.id=="gnomic"){
    +					f = 0.0015*(s.fov*s.d2r);
    +					dr = 0;
    +					if(typeof s.x=="number")
    +						dr = Math.min(Math.abs(s.x-x)*f/(Math.cos(s.dc_off)),Math.PI/36);
    +					if(typeof s.y=="number")
    +						s.dc_off -= (s.y-y)*f;
    +					s.ra_off -= (s.x-x > 0 ? 1 : -1)*dr;
    +					s.dc_off = inrangeEl(s.dc_off);
    +				}else{
    +					if(typeof s.x=="number")
    +						s.az_off += (s.x-x)/4;
    +				}
    +				s.az_off = s.az_off%360;
    +				s.x = x;
    +				s.y = y;
    +				s.draw("dragging");
    +			}
    +		}).on('touchstart',{sky:this},function(e){
    +			e.data.sky.debug('touchstart');
    +			e.data.sky.dragging = true;
    +			if(touchClickHandler){
    +				touchClickHandler.clickStart(getTouchXY(e.data.sky,e.originalEvent,this,e));
    +			}
    +		}).on('touchend',{sky:this},function(e){
    +			e.data.sky.debug('touchend');
    +			e.data.sky.dragging = false;
    +			e.data.sky.x = "";
    +			e.data.sky.y = "";
    +			e.data.sky.theta = "";
    +			if(touchClickHandler) {
    +				touchClickHandler.clickEnd(e);
    +			}
    +		}).on((isEventSupported('mousewheel') ? 'mousewheel' : 'wheel'),{sky:this},function(e) {
    +			e.preventDefault();
    +			e.data.sky.debug('mousewheel');
    +			var delta = -(e.originalEvent.deltaY || e.originalEvent.wheelDelta);
    +			if(!delta) delta = 0;
    +			var s = e.data.sky;
    +			if(s.mouse && s.projection.id=="gnomic"){
    +				s.changeFOV(delta).draw("mousewheel");
    +				return false;
    +			}else return true;
    +		});
    +		S(document).on('keypress',{sky:this},function(ev,b){
    +			if(!ev) ev = window.event;
    +			var e = ev.originalEvent;
    +			var code = e.keyCode || e.charCode || e.which || 0;
    +			ev.data.sky.keypress(code,ev.originalEvent);
    +		});
    +	}
    +
    +	this.registerKey('a',function(){ this.toggleAtmosphere(); },'atmos');
    +	this.registerKey('g',function(){ this.toggleGround(); },'ground');
    +	this.registerKey('h',function(){ this.cycleProjection(); },'projection');
    +	this.registerKey('i',function(){ this.toggleNegative(); },'neg');
    +	this.registerKey(',',function(){ this.toggleEcliptic(); },'ec');
    +	this.registerKey(';',function(){ this.toggleMeridian(); },'meridian');
    +	this.registerKey('e',function(){ this.toggleGridlinesEquatorial(); },'eq');
    +	this.registerKey('z',function(){ this.toggleGridlinesAzimuthal(); },'az');
    +	this.registerKey('m',function(){ this.toggleGridlinesGalactic(); },'gal');
    +	this.registerKey('M',function(){ this.toggleGalaxy(); },'galaxy');
    +	this.registerKey('q',function(){ this.toggleCardinalPoints(); },'cardinal');
    +	this.registerKey('s',function(){ this.toggleStars(); },'stars');
    +	this.registerKey('S',function(){ this.toggleStarLabels(); },'starlabels');
    +	this.registerKey('u',function(){ this.togglePlanetLabels(); },'sollabels');
    +	this.registerKey('p',function(){ this.togglePlanetHints(); },'sol');
    +	this.registerKey('o',function(){ this.toggleOrbits(); },'orbits');
    +	this.registerKey('c',function(){ this.toggleConstellationLines(); },'con');
    +	this.registerKey('v',function(){ this.toggleConstellationLabels(); },'names');
    +	this.registerKey('b',function(){ this.toggleConstellationBoundaries(); },'conbound');
    +	this.registerKey('R',function(){ this.toggleMeteorShowers(); },'meteorshowers');
    +	this.registerKey('1',function(){ this.toggleHelp(); });
    +	this.registerKey('8',function(){ this.setClock('now', 'reset').calendarUpdate(); },'reset');
    +	this.registerKey('j',function(){ if(!this.islive) this.spinIt("down"); },'slow');
    +	this.registerKey('k',function(){ this.spinIt(0); },'stop');
    +	this.registerKey('l',function(){ if(!this.islive) this.spinIt("up"); },'fast');
    +	this.registerKey('-',function(){ this.setClock(-86400, 'subtractday').calendarUpdate(); },'subtractday');
    +	this.registerKey('=',function(){ this.setClock(86400, 'addday').calendarUpdate(); },'addday');
    +	this.registerKey('[',function(){ this.setClock(-86400*7, 'subtractweek').calendarUpdate(); },'subtractweek');
    +	this.registerKey(']',function(){ this.setClock(86400*7, 'addweek').calendarUpdate(); },'addweek');
    +	// ALLSKY: use character instead of numbers for these.  Also, only change az 1, not 2.
    +	this.registerKey('%',function(){ this.az_off -= 1; this.draw("azleft"); },'azleft'); // left
    +	this.registerKey("'",function(){ this.az_off += 1; this.draw("azright"); },'azright'); // right
    +	this.registerKey('&',function(){ this.changeMagnitude(0.25); },'magup'); // up
    +	this.registerKey('(',function(){ this.changeMagnitude(-0.25);},'magdown'); // down
    +	this.registerKey('?',function(){ this.toggleHelp(); });
    +
    +	this.drawImmediate(undefined, "createSky");
    +};
    +
    +VirtualSky.prototype.changeMagnitude = function(m){
    +	if(typeof m!=="number")
    +		return this;
    +	this.magnitude += m;
    +	if(!this.starsdeep && this.magnitude > 4)
    +		this.load('stars',this.file.stars);
    +	else
    +		this.draw("changeMagnitude");
    +	return this;
    +};
    +
    +VirtualSky.prototype.toggleHelp = function(){
    +	var v = "virtualsky";
    +	if(S('.'+v+'_dismiss').length > 0) S('.'+v+'_dismiss').trigger('click');
    +	else{
    +		// Build the list of keyboard options
    +		var o = '';
    +		var i;
    +		for(i = 0; i < this.keys.length ; i++){
    +			if(this.keys[i].txt)
    +				o += '<li>'+
    +						'<strong class="'+v+'_help_key '+v+'_'+this.keys[i].txt+'">'+this.keys[i].str+'</strong> &rarr; <a href="#" class="'+v+'_'+this.keys[i].txt+'" style="text-decoration:none;">'+this.getPhrase(this.keys[i].txt)+'</a>'+
    +					'</li>'; }
    +		this.container.append('<div class="'+v+'_help">'+
    +			'<div class="'+v+'_dismiss" title="'+this.getPhrase('close')+'">&times;</div>'+
    +			'<div style="margin-bottom: 0.5em;">'+this.getPhrase('keyboard')+'</div>'+
    +			'<div class="'+v+'_helpinner"><ul></ul></div>'+
    +			'<div style="font-size:0.8em;margin-top: 0.5em;">'+this.lang.title+': '+this.version+'</div>'+
    +		'</div>');
    +
    +		var hlp = S('.'+v+'_help');
    +		var h = hlp.outerHeight();
    +
    +		// Add the keyboard option list
    +		hlp.find('ul').html(o);
    +
    +		// Set the maximum height for the list and add a scroll bar if necessary
    +		// ALLSKY ADDED "text-align:left" to override the "center" of the container
    +		S('.'+v+'_helpinner').css({'text-align':'left','overflow':'auto','max-height':(this.tall-h)+'px'});
    +
    +		// Add the events for each keyboard option
    +		for(i = 0; i < this.keys.length ; i++){
    +			if(this.keys[i].txt)
    +				// ALSKY DELETED "'opacity':1
    +				S('.'+v+'_'+this.keys[i].txt)
    +					.on('click',{fn:this.keys[i].fn,me:this},function(e){
    +						e.preventDefault(); e.data.fn.call(e.data.me);
    +					});
    +		}
    +
    +		// Create a lightbox
    +		this.createLightbox(S('.'+v+'_help'));
    +
    +		S('.'+v+'_help, .'+v+'_bg').on('mouseout',{sky:this},function(e){ e.data.sky.mouseover = false; }).on('mouseenter',{sky:this},function(e){ e.data.sky.mouseover = true; });
    +	}
    +};
    +// Register keyboard commands and associated functions
    +VirtualSky.prototype.registerKey = function(charCode,fn,txt){
    +	if(!is(fn,"function")) return this;
    +	if(!is(charCode,"object")) charCode = [charCode];
    +	var aok, ch, c, i, alt, str;
    +	for(c = 0 ; c < charCode.length ; c++){
    +		alt = false;
    +		if(typeof charCode[c]=="string"){
    +			if(charCode[c].indexOf('alt')==0){
    +				str = charCode[c];
    +				alt = true;
    +				charCode[c] = charCode[c].substring(4);
    +			}else{
    +				str = charCode[c];
    +			}
    +			ch = charCode[c].charCodeAt(0);
    +		}else{
    +			ch = charCode[c];
    +			var arrows = {37:"left",38:"up",39:"right",40:"down"};
    +			str = this.getPhrase(arrows[ch]) || String.fromCharCode(ch);
    +		}
    +		aok = true;
    +		for(i = 0 ; i < this.keys.length ; i++){ if(this.keys.charCode == ch && this.keys.altKey == alt) aok = false; }
    +		if(aok){
    +			this.keys.push({
    +				'str': str,
    +				'charCode': ch,
    +				'char': String.fromCharCode(ch),
    +				'fn': fn,
    +				'txt': txt,
    +				'altKey': alt
    +			});
    +		}
    +	}
    +	return this;
    +};
    +
    +// Work out if the keypress has a function that needs to be called.
    +VirtualSky.prototype.keypress = function(charCode,event){
    +	if(!event) event = { altKey: false };
    +	if(this.mouseover && this.keyboard){
    +		for(var i = 0 ; i < this.keys.length ; i++){
    +			if(this.keys[i].charCode == charCode && event.altKey == this.keys[i].altKey){
    +				this.keys[i].fn.call(this,{event:event});
    +				break;
    +			}
    +		}
    +	}
    +};
    +
    +VirtualSky.prototype.nearestObject = function(x,y){
    +	var i,e,t,d,ang,nearest;
    +	e = {};
    +	e.matched = this.whichPointer(x,y);
    +	var skyPos = this.xy2radec(x,y);
    +	if(skyPos){
    +		e.ra = skyPos.ra / this.d2r;
    +		e.dec = skyPos.dec / this.d2r;
    +	}
    +	d = 1e100;
    +	nearest = {};
    +	for(t in this.lookup){
    +		if(this.lookup[t]){
    +			for(i = 0; i < this.lookup[t].length; i++){
    +				ang = this.greatCircle(skyPos.ra,skyPos.dec,this.lookup[t][i].ra,this.lookup[t][i].dec);
    +				if(ang < d){
    +					nearest = {'distance':ang,'label':this.lookup[t][i].label+'','type':t,'data':this.lookup[t][i]};
    +					d = ang;
    +				}
    +			}
    +		}
    +	}
    +	nearest.distance /= this.d2r;
    +	if(nearest.type=="star") nearest.label = this.lang.starnames[nearest.label] || 'HIP'+nearest.label;
    +	return nearest;
    +};
    +
    +VirtualSky.prototype.whichPointer = function(x,y){
    +	for(var i = 0 ; i < this.pointers.length ; i++)
    +		if(Math.abs(x-this.pointers[i].x) < 5 && Math.abs(y-this.pointers[i].y) < 5)
    +			return i;
    +
    +	return -1;
    +};
    +VirtualSky.prototype.toggleInfoBox = function(i){
    +	if(this.pointers.length == 0 || i >= this.pointers.length || (i>=0 && !this.pointers[i].html))
    +		return this;
    +
    +	if(S('#'+this.id+'_'+this.infobox).length <= 0)
    +		this.container.append('<div id="'+this.id+'_'+this.infobox+'" class="'+this.infobox+'" style="display:none;"></div>');
    +	var el = S('#'+this.id+'_'+this.infobox);
    +	if(i >= 0 && this.isVisible(this.pointers[i].el) && this.pointers[i].x > 0 && this.pointers[i].y > 0 && this.pointers[i].x < this.wide && this.pointers[i].y < this.tall){
    +		el.html(this.pointers[i].html);
    +		var x = Math.round(this.pointers[i].x - Math.round(el.outerWidth()/2))+'px';
    +		var y = Math.round(this.pointers[i].y - Math.round(el.outerHeight()/2))+'px';
    +		el.css({'position':'absolute','left':x,'top':y,'z-index':10,'display':'block'});
    +	}else{
    +		el.css({'display':'none'});
    +	}
    +};
    +// compute horizon coordinates from utc, ra, dec
    +// ra, dec in radians
    +// lat, lon in  degrees
    +// results returned in hrz_altitude, hrz_azimuth
    +VirtualSky.prototype.coord2horizon = function(ra, dec){
    +	var ha, alt, az, sd, sl, cl;
    +	// compute hour angle in degrees
    +	ha = (Math.PI*this.times.LST/12) - ra;
    +	sd = Math.sin(dec);
    +	sl = Math.sin(this.latitude.rad);
    +	cl = Math.cos(this.latitude.rad);
    +	// compute altitude in radians
    +	alt = Math.asin(sd*sl + Math.cos(dec)*cl*Math.cos(ha));
    +	// compute azimuth in radians
    +	// divide by zero error at poles or if alt = 90 deg (so we should've already limited to 89.9999)
    +	az = Math.acos((sd - Math.sin(alt)*sl)/(Math.cos(alt)*cl));
    +	// choose hemisphere
    +	if (Math.sin(ha) > 0) az = 2*Math.PI - az;
    +	return [alt,az];
    +};
    +
    +// compute ra,dec coordinates from utc, horizon coords
    +// ra, dec in radians
    +// results returned in hrz_altitude, hrz_azimuth
    +VirtualSky.prototype.horizon2coord = function(coords){
    +	// Return angle in [0, 2 * PI[
    +	function Map2PI(angle){
    +		var n;
    +		var pipi = Math.PI * 2;
    +		if(angle < 0.0){
    +			n = Math.floor(angle / pipi);
    +			return (angle - n * pipi);
    +		}else if (angle >= pipi){
    +			n = Math.floor(angle / pipi);
    +			return (angle - n * pipi);
    +		}else  return (angle);
    +	}
    +
    +	// Return angle in [-PI, PI[
    +	function MapPI(angle) {
    +		var angle2PI = Map2PI(angle);
    +		if(angle2PI >= Math.PI) return (angle2PI - 2 * Math.PI);
    +		else return (angle2PI);
    +	}
    +
    +	function convertAltAzToALTAZ3D(i){
    +		var x = Math.sin(i.alt);
    +		const cs = Math.cos(i.alt);
    +		var z = cs * Math.cos(i.az);
    +		var y = cs * Math.sin(i.az);
    +		return [x, y, z];
    +	}
    +
    +	function rotate(xyz/*: number[]*/, axis/*: RotationDefinition*/, angle/*:number*/){
    +		const axes = [[1,2],[0,2],[0,1]];
    +		const a = axes[axis.id][0];
    +		const b = axes[axis.id][1];
    +		const cos = Math.cos(angle);
    +		const sin = Math.sin(angle);
    +		const ret = JSON.parse(JSON.stringify(xyz));	// Minify can't cope with ... notation
    +
    +		ret[a] = xyz[a] * cos - xyz[b] * sin;
    +		ret[b] = xyz[b] * cos + xyz[a] * sin;
    +
    +		return ret;
    +	}
    +
    +	function convertALTAZ3DToAltAz(xyz){
    +		return {'alt':MapPI(Math.asin(xyz[0])),'az':Map2PI(Math.atan2(xyz[1], xyz[2]))};
    +	}
    +	const xyz = convertAltAzToALTAZ3D({az: coords[1], alt: coords[0]});
    +	const rotated = rotate(xyz, {id: 1}, Math.PI/2 + this.latitude.rad);
    +	const res = convertALTAZ3DToAltAz(rotated);
    +
    +	return {ra: MapPI(res.az) + (Math.PI*this.times.LST/12), dec: -res.alt};
    +};
    +
    +function inrangeAz(a,deg){
    +	if(deg){
    +		while(a < 0) a += 360;
    +		while(a > 360) a -= 360;
    +	}else{
    +		var twopi = (2*Math.PI);
    +		while(a < 0) a += twopi;
    +		while(a > twopi) a -= twopi;
    +	}
    +	return a;
    +}
    +function inrangeEl(a,deg){
    +	if(deg){
    +		if(a >= 90) a = 89.99999;
    +		if(a <= -90) a = -89.99999;
    +	}else{
    +		if(a >= Math.PI/2) a = (Math.PI/2)*0.999999;
    +		if(a <= -Math.PI/2) a = (-Math.PI/2)*0.999999;
    +	}
    +	return a;
    +}
    +VirtualSky.prototype.selectProjection = function(proj){
    +	if(this.projections[proj]){
    +		this.projection = this.projections[proj];
    +		this.projection.id = proj;
    +		this.fullsky = this.projection.fullsky == true;
    +		this.polartype = this.projection.polartype == true;
    +
    +		// Set coordinate transforms
    +
    +		// Convert AZ,EL -> X,Y
    +		// Inputs: az (rad), el (rad), width (px), height (px)
    +		if(typeof this.projection.azel2xy==="function"){
    +			this.azel2xy = this.projection.azel2xy;
    +		}else{
    +			this.azel2xy = function(az,el,w,h){
    +				if(!w) w = this.wide;
    +				if(!h) h = this.tall;
    +				if(az < 0) az += 360;
    +				return {x:-1,y:-1,el:-1};
    +			};
    +		}
    +
    +		// Convert AZ,EL -> RA,Dec
    +		// Inputs: az (rad), el (rad)
    +		// Output: { ra: ra (deg), dec: dec (deg) }
    +		if(typeof this.projection.azel2radec==="function"){
    +			this.azel2radec = this.projection.azel2radec;
    +		}else{
    +			this.azel2radec = function(az,el){
    +				var xt,yt,r,l;
    +				l = this.latitude.rad;
    +				xt  =  Math.asin( Math.sin(el) * Math.sin(l) + Math.cos(el) * Math.cos(l) * Math.cos(az) );
    +				r = ( Math.sin(el) - Math.sin(l) * Math.sin(xt) ) / ( Math.cos(l) * Math.cos(xt) );
    +				if(r > 1) r = 1;
    +				yt  =  Math.acos(r);
    +				if(Math.sin(az) > 0.0) yt  =  Math.PI*2 - yt;
    +				xt *= this.r2d;
    +				yt *= this.r2d;
    +				yt = (this.times.LST*15 - yt + 360)%360.0;
    +				return { ra: yt, dec: xt };
    +			};
    +		}
    +
    +		if(this.ctx) this.updateSkyGradient();
    +
    +		this.updateColours();
    +
    +		// Draw update label
    +		if(this.container){
    +			var s = (this.lang.projections && this.lang.projections[proj]) ? this.lang.projections[proj] : this.projections[proj].title;
    +			if(S('.'+this.id+'_projection').length > 0) S('.'+this.id+'_projection').remove();
    +			this.container.append('<div class="'+this.id+'_projection">'+s+'</div>');
    +			var elem = S('.'+this.id+'_projection');
    +			elem.on('mouseover',{me:this},function(e){e.data.me.mouseover = true;})
    +				.css({
    +					'position':'absolute',
    +					'padding':0,
    +					'height':'2em',
    +					'top':'50%',
    +					'left':'50%',
    +					'transform':'translate3D(-50%,-50%,0)',
    +					'text-align':'center',
    +					'line-height':'2em',
    +					'z-index':20,
    +					'font-size':'1.5em',
    +					'display':'block',
    +					'overflow':'hidden',
    +					'background-color':'transparent',
    +					'color':(this.negative ? this.col.black : this.col.white)
    +				});
    +			setTimeout(function(e){
    +				e.fadeOut(1000,function(){ this.remove(); });
    +			},1500,elem);		// ALLSKY CHANGE: s/500/1500/ so the user has time to see the words.
    +		}
    +	}
    +};
    +// Cycle through the map projections
    +VirtualSky.prototype.cycleProjection = function(){
    +	var usenext = false;
    +	var proj = this.projection.id;
    +	var i = 0;
    +	var firstkey;
    +	for(var key in this.projections){
    +		if(this.projections[key]){
    +			if(i==0) firstkey = key;
    +			if(usenext){
    +				proj = key;
    +				break;
    +			}
    +			if(key == this.projection.id) usenext = true;
    +			i++;
    +		}
    +	}
    +	if(proj == this.projection.id) proj = firstkey;
    +	this.drawImmediate(proj, "cycleProjection");
    +};
    +// Update the sky colours
    +VirtualSky.prototype.updateColours = function(){
    +	// We need to make a copy of the correct colour palette otherwise it'll overwrite it
    +	this.col = JSON.parse(JSON.stringify( ((this.negative) ? this.colours.negative : this.colours.normal) ));
    +	//this.col = $.extend(true, {}, ((this.negative) ? this.colours.negative : this.colours.normal));
    +	if(this.color==""){
    +		if((this.polartype || this.projection.altlabeltext))
    +			this.col.txt = this.col.grey;
    +	}else{
    +		this.col.txt = this.color;
    +	}
    +};
    +
    +VirtualSky.prototype.isVisible = function(el){
    +	if(typeof this.projection.isVisible==="function") return this.projection.isVisible.call(el);
    +	if(!this.fullsky) return (el > 0);
    +	else return (this.ground) ? (el > 0) : true;
    +};
    +VirtualSky.prototype.isPointBad = function(p){
    +	return p.x==-1 && p.y==-1;
    +};
    +// Return a structure with the Julian Date, Local Sidereal Time and Greenwich Sidereal Time
    +VirtualSky.prototype.astronomicalTimes = function(clock,lon){
    +	clock = clock || this.clock;
    +	lon = lon || this.longitude.deg;
    +	var JD,JD0,S,T,T0,UT,A,GST,d,LST;
    +	JD = this.getJD(clock);
    +	JD0 = Math.floor(JD-0.5)+0.5;
    +	S = JD0-2451545.0;
    +	T = S/36525.0;
    +	T0 = (6.697374558 + (2400.051336*T) + (0.000025862*T*T))%24;
    +	if(T0 < 0) T0 += 24;
    +	UT = (((clock.getUTCMilliseconds()/1000 + clock.getUTCSeconds())/60) + clock.getUTCMinutes())/60 + clock.getUTCHours();
    +	A = UT*1.002737909;
    +	T0 += A;
    +	GST = T0%24;
    +	if(GST < 0) GST += 24;
    +	d = (GST + lon/15.0)/24.0;
    +	d = d - Math.floor(d);
    +	if(d < 0) d += 1;
    +	LST = 24.0*d;
    +	return { GST:GST, LST:LST, JD:JD };
    +};
    +// Uses algorithm defined in Practical Astronomy (4th ed) by Peter Duffet-Smith and Jonathan Zwart
    +VirtualSky.prototype.moonPos = function(JD,sun){
    +	var d2r,lo,Po,No,i,e,l,Mm,N,C,Ev,sinMo,Ae,A3,Mprimem,Ec,A4,lprime,V,lprimeprime,Nprime,lppNp,sinlppNp,y,x,lm,Bm;
    +	d2r = this.d2r;
    +	JD = JD || this.times.JD;
    +	sun = sun || this.sunPos(JD);
    +	lo = 91.929336;	// Moon's mean longitude at epoch 2010.0
    +	Po = 130.143076;	// mean longitude of the perigee at epoch
    +	No = 291.682547;	// mean longitude of the node at the epoch
    +	i = 5.145396;	// inclination of Moon's orbit
    +	e = 0.0549;	// eccentricity of the Moon's orbit
    +	l = (13.1763966*sun.D + lo)%360;
    +	if(l < 0) l += 360;
    +	Mm = (l - 0.1114041*sun.D - Po)%360;
    +	if(Mm < 0) Mm += 360;
    +	N = (No - 0.0529539*sun.D)%360;
    +	if(N < 0) N += 360;
    +	C = l-sun.lon;
    +	Ev = 1.2739*Math.sin((2*C-Mm)*d2r);
    +	sinMo = Math.sin(sun.Mo*d2r);
    +	Ae = 0.1858*sinMo;
    +	A3 = 0.37*sinMo;
    +	Mprimem = Mm + Ev -Ae - A3;
    +	Ec = 6.2886*Math.sin(Mprimem*d2r);
    +	A4 = 0.214*Math.sin(2*Mprimem*d2r);
    +	lprime = l + Ev + Ec -Ae + A4;
    +	V = 0.6583*Math.sin(2*(lprime-sun.lon)*d2r);
    +	lprimeprime = lprime + V;
    +	Nprime = N - 0.16*sinMo;
    +	lppNp = (lprimeprime-Nprime)*d2r;
    +	sinlppNp = Math.sin(lppNp);
    +	y = sinlppNp*Math.cos(i*d2r);
    +	x = Math.cos(lppNp);
    +	lm = Math.atan2(y,x)/d2r + Nprime;
    +	Bm = Math.asin(sinlppNp*Math.sin(i*d2r))/d2r;
    +	if(lm > 360) lm -= 360;
    +	return { moon: {lon:lm,lat:Bm}, sun:sun };
    +};
    +// Uses algorithm defined in Practical Astronomy (4th ed) by Peter Duffet-Smith and Jonathan Zwart
    +VirtualSky.prototype.sunPos = function(JD){
    +	var D,eg,wg,e,N,Mo,v,lon,lat;
    +	D = (JD-2455196.5);	// Number of days since the epoch of 2010 January 0.0
    +	// Calculated for epoch 2010.0. If T is the number of Julian centuries since 1900 January 0.5 = (JD-2415020.0)/36525
    +	eg = 279.557208;	// mean ecliptic longitude in degrees = (279.6966778 + 36000.76892*T + 0.0003025*T*T)%360;
    +	wg = 283.112438;	// longitude of the Sun at perigee in degrees = 281.2208444 + 1.719175*T + 0.000452778*T*T;
    +	e = 0.016705;	// eccentricity of the Sun-Earth orbit in degrees = 0.01675104 - 0.0000418*T - 0.000000126*T*T;
    +	N = ((360/365.242191)*D)%360;
    +	if(N < 0) N += 360;
    +	Mo = (N + eg - wg)%360;	// mean anomaly in degrees
    +	if(Mo < 0) Mo += 360;
    +	v = Mo + (360/Math.PI)*e*Math.sin(Mo*Math.PI/180);
    +	lon = v + wg;
    +	if(lon > 360) lon -= 360;
    +	lat = 0;
    +	return {lat:lat,lon:lon,Mo:Mo,D:D,N:N};
    +};
    +// Input is Julian Date
    +// Uses method defined in Practical Astronomy (4th ed) by Peter Duffet-Smith and Jonathan Zwart
    +VirtualSky.prototype.meanObliquity = function(JD){
    +	if(!JD) JD = this.times.JD;
    +	var T,T2,T3;
    +	T = (JD-2451545.0)/36525;	// centuries since 2451545.0 (2000 January 1.5)
    +	T2 = T*T;
    +	T3 = T2*T;
    +	return (23.4392917 - 0.0130041667*T - 0.00000016667*T2 + 0.0000005027778*T3)*this.d2r;
    +};
    +// Take input in radians, decimal Sidereal Time and decimal latitude
    +// Uses method defined in Practical Astronomy (4th ed) by Peter Duffet-Smith and Jonathan Zwart
    +VirtualSky.prototype.ecliptic2azel = function(l,b,LST,lat){
    +	if(!LST){
    +		this.times = this.astronomicalTimes();
    +		LST = this.times.LST;
    +	}
    +	if(!lat) lat = this.latitude.rad;
    +	var sl,cl,sb,cb,v,e,ce,se,Cprime,s,ST,cST,sST,B,r,sphi,cphi,A,w,theta,psi;
    +	sl = Math.sin(l);
    +	cl = Math.cos(l);
    +	sb = Math.sin(b);
    +	cb = Math.cos(b);
    +	v = [cl*cb,sl*cb,sb];
    +	e = this.meanObliquity();
    +	ce = Math.cos(e);
    +	se = Math.sin(e);
    +	Cprime = [[1.0,0.0,0.0],[0.0,ce,-se],[0.0,se,ce]];
    +	s = this.vectorMultiply(Cprime,v);
    +	ST = LST*15*this.d2r;
    +	cST = Math.cos(ST);
    +	sST = Math.sin(ST);
    +	B = [[cST,sST,0],[sST,-cST,0],[0,0,1]];
    +	r = this.vectorMultiply(B,s);
    +	sphi = Math.sin(lat);
    +	cphi = Math.cos(lat);
    +	A = [[-sphi,0,cphi],[0,-1,0],[cphi,0,sphi]];
    +	w = this.vectorMultiply(A,r);
    +	theta = Math.atan2(w[1],w[0]);
    +	psi = Math.asin(w[2]);
    +	return {az:theta,el:psi};
    +};
    +// Convert from ecliptic l,b -> RA,Dec
    +// Inputs: l (rad), b (rad), Julian date
    +VirtualSky.prototype.ecliptic2radec = function(l,b,JD){
    +	var e = this.meanObliquity();
    +	var sl = Math.sin(l);
    +	var cl = Math.cos(l);
    +	var sb = Math.sin(b);
    +	var cb = Math.cos(b);
    +	var tb = Math.tan(b);
    +	var se = Math.sin(e);
    +	var ce = Math.cos(e);
    +	var ra = Math.atan2((sl*ce - tb*se),(cl));
    +	var dec = Math.asin(sb*ce+cb*se*sl);
    +	// Make sure RA is positive
    +	if(ra < 0) ra += Math.PI+Math.PI;
    +	return { ra:ra, dec:dec };
    +};
    +// Convert Ecliptic coordinates to x,y position
    +// Inputs: l (rad), b (rad), local sidereal time
    +// Returns [x, y (,elevation)]
    +VirtualSky.prototype.ecliptic2xy = function(l,b,LST){
    +	LST = LST || this.times.LST;
    +	var pos;
    +	if(typeof this.projection.ecliptic2xy==="function") return this.projection.ecliptic2xy.call(this,l,b,LST);
    +	else{
    +		if(this.fullsky){
    +			pos = this.ecliptic2radec(l,b);
    +			return this.radec2xy(pos.ra,pos.dec);
    +		}else{
    +			pos = this.ecliptic2azel(l,b,LST);
    +			var el = pos.el*this.r2d;
    +			pos = this.azel2xy(pos.az-(this.az_off*this.d2r),pos.el,this.wide,this.tall);
    +			pos.el = el;
    +			return pos;
    +		}
    +	}
    +	return 0;
    +};
    +
    +// Convert RA,Dec -> X,Y
    +// Inputs: RA (rad), Dec (rad)
    +// Returns [x, y (,elevation)]
    +VirtualSky.prototype.radec2xy = function(ra,dec){
    +	if(typeof this.projection.radec2xy==="function") return this.projection.radec2xy.call(this,ra,dec);
    +	else{
    +		var coords = this.coord2horizon(ra, dec);
    +		// Only return coordinates above the horizon
    +		//if(coords[0] > 0){
    +			var pos = this.azel2xy(coords[1]-(this.az_off*this.d2r),coords[0],this.wide,this.tall);
    +			return {x:pos.x,y:pos.y,az:coords[1]*this.r2d,el:coords[0]*this.r2d};
    +		//}
    +	}
    +	return 0;
    +};
    +
    +// Returns {ra (rad), dec (rad)}
    +VirtualSky.prototype.xy2radec = function(x, y){
    +	if (typeof this.projection.xy2radec==="function") return this.projection.xy2radec.call(this,x,y);
    +	else if (typeof this.projection.xy2azel === "function") {
    +		var azel = this.projection.xy2azel(x, y,this.wide,this.tall);
    +		if (azel === undefined) {
    +			return undefined;
    +		}
    +
    +		var coords = [azel[1], azel[0] + (this.az_off*this.d2r)];
    +
    +		return this.horizon2coord(coords);
    +	} else {
    +		return undefined;
    +	}
    +};
    +
    +// Dummy function - overwritten in selectProjection
    +// Convert AZ,EL -> X,Y
    +// Inputs: az (degrees), el (degrees), width (px), height (px)
    +// Output: { x: x, y: y }
    +VirtualSky.prototype.azel2xy = function(az,el,w,h){ return {x:-1,y:-1}; };
    +
    +// Dummy functions - overwritten in selectProjection
    +// Convert AZ,EL -> RA,Dec
    +// Inputs: az (rad), el (rad)
    +// Output: { ra: ra (deg), dec: dec (deg) }
    +VirtualSky.prototype.azel2radec = function(az,el){ return { ra: 0, dec: 0 }; };
    +
    +// Convert Galactic -> x,y
    +// Inputs: longitude (rad), latitude (rad)
    +VirtualSky.prototype.gal2xy = function(l,b){
    +	var pos = this.gal2radec(l,b);
    +	return this.radec2xy(pos[0],pos[1]);
    +};
    +
    +// Convert Galactic -> J2000
    +// Inputs: longitude (rad), latitude (rad)
    +VirtualSky.prototype.gal2radec = function(l,b){
    +	// Using SLALIB values
    +	return this.Transform([l,b], [-0.054875539726, 0.494109453312, -0.867666135858, -0.873437108010, -0.444829589425, -0.198076386122, -0.483834985808, 0.746982251810, 0.455983795705],false);
    +};
    +
    +// Input is a two element position (degrees) and rotation matrix
    +// Output is a two element position (degrees)
    +VirtualSky.prototype.Transform = function(p, rot, indeg){
    +	if(indeg){
    +		p[0] *= this.d2r;
    +		p[1] *= this.d2r;
    +	}
    +	var cp1 = Math.cos(p[1]);
    +	var m = [Math.cos(p[0])*cp1, Math.sin(p[0])*cp1, Math.sin(p[1])];
    +	var s = [m[0]*rot[0] + m[1]*rot[1] + m[2]*rot[2], m[0]*rot[3] + m[1]*rot[4] + m[2]*rot[5], m[0]*rot[6] + m[1]*rot[7] + m[2]*rot[8] ];
    +	var r = Math.sqrt(s[0]*s[0] + s[1]*s[1] + s[2]*s[2]);
    +	var b = Math.asin(s[2]/r); // Declination in range -90 -> +90
    +	var cb = Math.cos(b);
    +	var a = Math.atan2(((s[1]/r)/cb),((s[0]/r)/cb));
    +	if (a < 0) a += Math.PI*2;
    +	if(indeg) return [a*this.r2d,b*this.r2d];
    +	else return [a,b];
    +};
    +// Convert from B1875 to J2000
    +// Using B = 1900.0 + (JD - 2415020.31352) / 365.242198781 and p73 Practical Astronomy With A Calculator
    +VirtualSky.prototype.fk1tofk5 = function(a,b){
    +	// Convert from B1875 -> J2000
    +	return this.Transform([a,b], [0.9995358730015703, -0.02793693620138929, -0.012147682028606801, 0.027936935758478665, 0.9996096732234282, -0.00016976035344812515, 0.012147683047201562, -0.00016968744936278707, 0.9999261997781408]);
    +};
    +VirtualSky.prototype.vectorMultiply = function(A,B){
    +	if(B.length > 0){
    +		// 2D (3x3)x(3x3) or 1D (3x3)x(3x1)
    +		if(B[0].length > 0) return [[(A[0][0]*B[0][0]+A[0][1]*B[1][0]+A[0][2]*B[2][0]),(A[0][0]*B[0][1]+A[0][1]*B[1][1]+A[0][2]*B[2][1]),(A[0][0]*B[0][2]+A[0][1]*B[1][2]+A[0][2]*B[2][2])],
    +									[(A[1][0]*B[0][0]+A[1][1]*B[1][0]+A[1][2]*B[2][0]),(A[1][0]*B[0][1]+A[1][1]*B[1][1]+A[1][2]*B[2][1]),(A[1][0]*B[0][2]+A[1][1]*B[1][2]+A[1][2]*B[2][2])],
    +									[(A[2][0]*B[0][0]+A[2][1]*B[1][0]+A[2][2]*B[2][0]),(A[2][0]*B[0][1]+A[2][1]*B[1][1]+A[2][2]*B[2][1]),(A[2][0]*B[0][2]+A[2][1]*B[1][2]+A[2][2]*B[2][2])]];
    +		else return [(A[0][0]*B[0] + A[0][1]*B[1] + A[0][2]*B[2]),(A[1][0]*B[0] + A[1][1]*B[1] + A[1][2]*B[2]),(A[2][0]*B[0] + A[2][1]*B[1] + A[2][2]*B[2])];
    +	}
    +};
    +VirtualSky.prototype.setFont = function(){ this.ctx.font = this.fontsize()+"px "+this.canvas.css('font-family'); };
    +VirtualSky.prototype.fontsize = function(){
    +	if(this.fntsze) return parseInt(this.fntsze);
    +	var m = Math.min(this.wide,this.tall);
    +	return (m < 600) ? ((m < 500) ? ((m < 350) ? ((m < 300) ? ((m < 250) ? 9 : 10) : 11) : 12) : 14) : parseInt(this.container.css('font-size'));
    +};
    +VirtualSky.prototype.positionCredit = function(){
    +	this.container.find('.'+this.id+'_credit').css({position:'absolute',top:(parseFloat(this.tall)-this.padding-this.fontsize())+'px',left:this.padding+'px'});
    +};
    +VirtualSky.prototype.updateSkyGradient = function(){
    +	var s = null;
    +	if(this.ctx && this.hasGradient()){
    +		if(this.projection.polartype){
    +			if(typeof this.ctx.createRadialGradient==="function"){
    +				s = this.ctx.createRadialGradient(this.wide/2,this.tall/2,0,this.wide/2,this.tall/2,this.tall/2);
    +				s.addColorStop(0, 'rgba(0,0,0,1)');
    +				s.addColorStop(0.7, 'rgba(0,0,0,0.2)');
    +				s.addColorStop(1, 'rgba(0,50,80,0.3)');
    +			}
    +		}else{
    +			s = this.ctx.createLinearGradient(0,0,0,this.tall);
    +			s.addColorStop(0.0, 'rgba(0,30,50,0.1)');
    +			s.addColorStop(0.7, 'rgba(0,30,50,0.35)');
    +			s.addColorStop(1, 'rgba(0,50,80,0.6)');
    +		}
    +	}
    +	this.skygrad = s;
    +	return this;
    +};
    +
    +// ALLSKY ADDED "whofrom" argument to aid debugging.  It says who called draw().
    +VirtualSky.prototype.draw = function(whofrom){
    +//console.log("draw() called from " + whofrom + ", calling drawImmediate()");
    +	// Redraw within 20ms. Used to avoid redraw pilling up, introducing vast lag
    +	if(this.pendingRefresh !== undefined) return;
    +	this.pendingRefresh = window.setTimeout(this.drawImmediate.bind(this), 20);
    +};
    +
    +VirtualSky.prototype.invokeDrawCb = function(visible){
    +	if (typeof this.callback.draw == "function") {	// ALLSKY ADDED "== function"
    +		var self = this;
    +		function callCb() {
    +			self.callback.draw(visible);
    +		}
    +		window.setTimeout(callCb, 0);
    +	}
    +}
    +
    +// ALLSKY ADDED "whofrom" argument to aid debugging.  It says who called drawImmediate().
    +VirtualSky.prototype.drawImmediate = function(proj, whofrom){
    +// console.log("drawImmediate() called by " + whofrom);
    +	// Don't bother drawing anything if there is no physical area to draw on
    +	if(this.pendingRefresh !== undefined){
    +		window.clearTimeout(this.pendingRefresh);
    +		this.pendingRefresh = undefined;
    +	}
    +
    +	if(this.wide <= 0 || this.tall <= 0) {
    +		this.invokeDrawCb(false);
    +		return this;
    +	}
    +	if(!(this.c && this.c.getContext)) {
    +		this.invokeDrawCb(false);
    +		return this;
    +	}
    +
    +	if(proj !== undefined) this.selectProjection(proj);
    +	var white = this.col.white;
    +	var black = this.col.black;
    +	var i,off,clockstring,metric_clock,positionstring,metric_pos;
    +
    +	// Shorthands
    +	var c = this.ctx;
    +	var d = this.container;
    +
    +	c.moveTo(0,0);
    +	c.clearRect(0,0,this.wide,this.tall);
    +	c.fillStyle = (this.polartype || this.fullsky) ? this.background : ((this.negative) ? white : black);
    +	c.fillRect(0,0,this.wide,this.tall);
    +	c.fill();
    +
    +	if(this.polartype){
    +		c.moveTo(this.wide/2,this.tall/2);
    +		c.closePath();
    +		c.beginPath();
    +		c.arc(this.wide/2,this.tall/2,-0.5+this.tall/2,0,Math.PI*2,true);
    +		c.closePath();
    +		if(!this.transparent){
    +			c.fillStyle = (this.hasGradient()) ? "rgba(0,15,30, 1)" : ((this.negative) ? white : black);
    +			c.fill();
    +		}
    +		c.lineWidth = 0.5;
    +		c.strokeStyle = black;
    +		c.stroke();
    +	}else if(typeof this.projection.draw==="function") this.projection.draw.call(this);
    +
    +	if(this.hasGradient()){
    +		if(!this.skygrad){
    +			this.updateSkyGradient();
    +		}else{
    +			c.beginPath();
    +			c.fillStyle = this.skygrad;
    +			// draw shapes
    +			if(this.projection.polartype){ c.arc(this.wide/2,this.tall/2,this.tall/2,0,2*Math.PI,false); c.fill(); }
    +			else c.fillRect(0,0,this.wide,this.tall);
    +			c.closePath();
    +		}
    +	}
    +
    +	this.startClip()
    +		.drawGridlines("az")
    +		.drawGridlines("eq")
    +		.drawGridlines("gal")
    +		.drawGalaxy()
    +		.drawConstellationLines()
    +		.drawConstellationBoundaries()
    +		.drawStars()
    +		.drawEcliptic()
    +		.drawMeridian()
    +		.drawPlanets()
    +		.drawMeteorShowers()
    +		.endClip()
    +		.drawCardinalPoints();
    +
    +	for(i = 0; i < this.pointers.length ; i++) this.highlight(i);
    +
    +	var txtcolour = (this.color!="") ? (this.color) : this.col.txt;
    +	var fontsize = this.fontsize();
    +
    +	c.fillStyle = txtcolour;
    +	c.lineWidth = 1.5;
    +	this.setFont();
    +	this.container.css({'font-size':this.fontsize()+'px','position':'relative'});
    +
    +	// Time line
    +	if(this.showdate){
    +		clockstring = this.clock.toLocaleDateString(this.langcode,{ weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })+' '+this.clock.toLocaleTimeString(this.langcode);
    +		metric_clock = this.drawText(clockstring,this.padding,this.padding+fontsize);
    +	}
    +
    +	// Position line
    +	if(this.showposition){
    +		positionstring = Math.abs(this.latitude.deg).toFixed(2) + ((this.latitude.rad>0) ? this.getPhrase('N') : this.getPhrase('S')) + ', ' + Math.abs(this.longitude.deg).toFixed(2) + ((this.longitude.rad>0) ? this.getPhrase('E') : this.getPhrase('W'));
    +		metric_pos = this.drawText(positionstring,this.padding,this.padding+fontsize+fontsize);
    +	}
    +
    +	// Credit line
    +	if(this.credit){
    +		var credit = this.getPhrase('power');
    +		var metric_credit = this.drawText(credit,this.padding,this.tall-this.padding);
    +		// Float a transparent link on top of the credit text
    +		if(d.find('.'+this.id+'_credit').length == 0) d.append('<div class="'+this.id+'_credit"><a href="http://slowe.github.io/VirtualSky/" target="_parent" title="Las Cumbres Observatory">'+this.getPhrase('powered')+'</a></div>');
    +		d.find('.'+this.id+'_credit').css({padding:0,'z-index':20,display:'block',overflow:'hidden','background-color':'transparent'});
    +		d.find('.'+this.id+'_credit a').css({display:'block',width:Math.ceil(metric_credit)+'px',height:fontsize+'px'});
    +		this.positionCredit();
    +	}
    +
    +	if(this.showhelp){
    +		var helpstr = '?';
    +		if(d.find('.'+this.id+'_help').length == 0)
    +			d.append('<div class="'+this.id+'_help"><a href="#">'+helpstr+'</a></div>')
    +			 .find('.'+this.id+'_help')
    +			 .css({
    +				position:'absolute',
    +				'padding':this.padding+'px',
    +				'z-index':20,
    +				display:'block',
    +				overflow:'hidden',
    +				'background-color':'transparent',
    +				'right':0+'px',
    +				'top':0+'px'
    +			}).find('a').css({
    +				'text-decoration':'none',
    +				color:txtcolour
    +			}).on('click',{me:this},function(e){ e.data.me.toggleHelp(); });
    +		d.find('.'+this.id+'_help').find('a').css({color:txtcolour});
    +	}
    +	// Make help button
    +	if(this.container.find('.'+this.id+'_btn_help').length == 0){
    +		this.container.append('<div class="'+this.id+'_btn_help virtualskybutton" title="'+this.getPhrase('help')+'">?</div>');
    +		off = S('#'+this.idinner).position();
    +		this.container.find('.'+this.id+'_btn_help').css({
    +			'position':'absolute',
    +			'top':(off.top+this.padding)+'px',
    +			'right':this.padding+'px',
    +			'opacity':this.opacity,		// ALLSKY added
    +			'z-index':20
    +		}).on('click',{me:this},function(e){
    +			e.data.me.toggleHelp();
    +		});
    +	}
    +
    +// ALLSKY ADDED "this.showdate"
    +	if(this.showdate && this.container.find('.'+this.id+'_clock').length == 0){
    +		this.container.append('<div class="'+this.id+'_clock" title="'+this.getPhrase('datechange')+'">'+clockstring+'</div>');
    +		off = S('#'+this.idinner).position();
    +		this.container.find('.'+this.id+'_clock').css({
    +			'position':'absolute',
    +			'padding':0+'px',
    +			'width':Math.round(metric_clock)+'px',
    +			cursor:'pointer',
    +			top:(off.top+this.padding)+'px',
    +			left:(off.left+this.padding)+'px',
    +			'z-index':20,
    +			display:'block',
    +			overflow:'hidden',
    +			'background-color':'transparent',
    +			// ALLSKY COMMENT: transparent is hard to see, but setting to a color causes the clock to not update
    +			color:'transparent'
    +		}).on('click',{sky:this},function(e){
    +			var s = e.data.sky;
    +			var id = s.id;
    +			var hid = '#'+id;
    +			var v = "virtualsky";
    +			if(S(hid+'_calendar').length == 0){
    +				var w = 280;
    +				if(s.wide < w) w = s.wide;
    +				s.container.append(
    +					'<div id="'+id+'_calendar" class="'+v+'form">'+
    +						'<div style="" id="'+id+'_calendar_close" class="'+v+'_dismiss" title="'+s.getPhrase('close')+'">&times;</div>'+
    +						'<div style="text-align:center;margin:2px;">'+s.getPhrase('date')+'</div>'+
    +						'<div style="text-align:center;">'+
    +							'<input type="date" id="'+id+'_date" value="'+(s.clock.getFullYear()+'-'+((s.clock.getMonth() < 9 ? "0":"")+(s.clock.getMonth()+1))+'-'+(s.clock.getDate() < 10 ? "0":"")+s.clock.getDate())+'" />' +
    +							'<input type="time" id="'+id+'_time" value="'+(s.clock.getHours() < 10 ? '0':'')+s.clock.getHours()+':'+(s.clock.getMinutes() < 10 ? '0':'')+s.clock.getMinutes()+'" />' +
    +						'</div>'+
    +					'</div>');
    +				S(hid+'_calendar').css({width:w});
    +				S(hid+'_calendar input').on('change',{sky:s},function(e){
    +					var d = S('#'+id+'_date').val();
    +					var t = S('#'+id+'_time').val();
    +					e.data.sky.updateClock(new Date(parseInt(d.substr(0,4)), parseInt(d.substr(5,2))-1, parseInt(d.substr(8,2)), parseInt(t.substr(0,2)), parseInt(t.substr(3,2)), 0,0));
    +					e.data.sky.calendarUpdate();
    +					e.data.sky.draw("calendarChange");
    +				});
    +			}
    +			s.createLightbox(S(hid+'_calendar'));
    +			S(hid+'_year').val(s.clock.getFullYear());
    +			S(hid+'_month').val(s.clock.getMonth()+1);
    +			S(hid+'_day').val(s.clock.getDate());
    +			S(hid+'_hours').val(s.clock.getHours());
    +			S(hid+'_mins').val(s.clock.getMinutes());
    +		});
    +	}
    +
    +// ALLSKY ADDED "this.showposition"
    +	if(this.showposition && S('.'+this.id+'_position').length == 0){
    +		this.container.append('<div class="'+this.id+'_position" title="'+this.getPhrase('positionchange')+'">'+positionstring+'</div>');
    +		S('.'+this.id+'_position').on('click',{sky:this},function(e){
    +			var s = e.data.sky;
    +			var id = s.id;
    +			var hid = '#'+id;
    +			var v = "virtualsky";
    +			if(S(hid+'_geo').length == 0){
    +				var w = 310;
    +				var narrow = '';
    +				if(s.wide < w){
    +					narrow = '<br style="clear:both;margin-top:20px;" />';
    +					w = w/2;
    +				}
    +				s.container.append(
    +					'<div id="'+id+'_geo" class="'+v+'form">'+
    +						'<div id="'+id+'_geo_close" class="'+v+'_dismiss" title="'+s.getPhrase('close')+'">&times;</div>'+
    +						'<div style="text-align:center;margin:2px;">'+s.getPhrase('position')+'</div>'+
    +						'<div style="text-align:center;">'+
    +							'<input type="text" id="'+id+'_lat" value="" style="padding-right:10px!important;">'+
    +							'<div class="divider">'+s.getPhrase('N')+'</div>'+
    +							narrow+'<input type="text" id="'+id+'_long" value="" />'+
    +							'<div class="divider">'+s.getPhrase('E')+'</div>'+
    +						'</div>'+
    +					'</div>');
    +				S(hid+'_geo').css({width:w+'px','align':'center'});
    +				S(hid+'_geo input').css({width:'6em'});
    +			}
    +			s.createLightbox(S(hid+'_geo'),{
    +				'close': function(e){
    +					if(this.vs) this.vs.setGeo(S(hid+'_lat').val()+','+S(hid+'_long').val()).setClock(0, 'close').draw("createLightbox.close");
    +				}
    +			});
    +			S(hid+'_lat').val(s.latitude.deg);
    +			S(hid+'_long').val(s.longitude.deg);
    +			if(typeof s.callback.geo=="function") s.callback.geo.call(s);
    +		});
    +	}
    +	this.invokeDrawCb(true);
    +	off = S('#'+this.idinner).position();
    +	S('.'+this.id+'_position').css({
    +		position:'absolute',
    +		padding:0,
    +		'width':Math.round(metric_pos)+'px',
    +		cursor:'pointer',
    +		top:(off.top+this.padding+fontsize)+'px',
    +		left:(off.left+this.padding)+'px',
    +		'z-index':20,
    +		display:'block',
    +		overflow:'hidden',
    +		'background-color':'transparent',
    +		color:'transparent'
    +	});
    +	return this;
    +};
    +VirtualSky.prototype.startClip = function(){
    +	if(this.polartype){
    +		this.ctx.save();
    +		this.ctx.beginPath();
    +		this.ctx.arc(this.wide/2,this.tall/2,-0.5+this.tall/2,0,Math.PI*2,true);
    +		this.ctx.clip();
    +	}
    +	return this;
    +};
    +VirtualSky.prototype.endClip = function(){
    +	if(this.polartype) this.ctx.restore();
    +	return this;
    +};
    +VirtualSky.prototype.createLightbox = function(lb,opts){
    +	if(!lb.length) return this;
    +	if(!opts) opts = {};
    +
    +	function Lightbox(lb,vs,opts){
    +		this.lb = lb;
    +		this.vs = vs;
    +		this.opts = opts || {};
    +
    +		var n = "virtualsky_bg";
    +		if(this.vs.container.find('.'+n).length == 0) this.vs.container.append('<div class="'+n+'" style="position:absolute;z-index:99;left:0px;top:0px;right:0px;bottom:0px;background-color:rgba(0,0,0,0.4);"></div>');	// ALLSKY: s/0.7/0.4/
    +		var bg = this.vs.container.find('.'+n);
    +		if(bg.length > 0) bg.show();
    +		this.bg = bg;
    +		this.vs.container.find('.virtualsky_dismiss').on('click',{lb:this},function(e){ e.data.lb.close(); });
    +		bg.on('click',{lightbox:this},function(e){ e.data.lightbox.close(); });
    +		// Update lightbox when the screen is resized
    +		this.vs.on('resize',function(e){ if(this.lightbox) this.lightbox.resize(); });
    +		// Set positions
    +		this.resize();
    +
    +		return this;
    +	}
    +	Lightbox.prototype.resize = function(){
    +
    +		function columize(wide,tall){
    +			// Make each li as wide as it needs to be so we can calculate the widest
    +			lb.find('li').css({'display':'inline-block','margin-left':'0px','width':'auto'});
    +			// Remove positioning so we can work out sizes
    +			lb.find('ul').css({'width':'auto'});
    +			var w = lb.outerWidth();
    +			var bar = 24;
    +			var li = lb.find('ul li');
    +			var mx = 1;
    +			for(var i = 0 ; i < li.length; i++){
    +				if(S(li[i]).width() > mx) mx = S(li[i]).width();
    +			}
    +			// If the list items are wider than the space we have we turn them
    +			// into block items otherwise set their widths to the maximum width.
    +			var n = Math.floor(w/(mx+bar));
    +			if(n > 1){
    +				if(n > 3) n = 3;
    +				lb.find('li').css({'width':(100/n)+'%','border-left':Math.floor(bar/2)+'px solid transparent','box-sizing':'border-box'});
    +				lb.find('li:nth-child('+n+'n+1)').css({'margin-left':'0px'});
    +			}else{
    +				lb.find('li').css({'display':'block','width':'auto'});
    +			}
    +			lb.find('ul').css({'width':'100%'}).parent().css({'width':(w <= 500 ? '100%' : Math.min(w-bar,(mx+bar/2)*n + bar)+'px')});
    +		}
    +		columize.call(this.vs.wide,this.vs.tall);
    +		this.lb.css({'position':'relative',left:'50%',top:'50%','transform':'translate3d(-50%,-50%,0)','max-height':'100%','box-sizing':'border-box','z-index': 100,'position': 'absolute'});
    +		if(lb.outerWidth() <= 500) this.lb.css({'width':'100%'});	
    +		return this;
    +	};
    +	Lightbox.prototype.close = function(){
    +		// Trigger any close function provided
    +		if(typeof this.opts.close==="function") this.opts.close.call(this);
    +
    +		// Now remove the lightbox DOM
    +		this.lb.remove();
    +		this.bg.remove();
    +		this.vs.lightbox = null;
    +		return this;
    +	};
    +
    +	this.lightbox = new Lightbox(lb,this,opts);
    +
    +	return this;
    +};
    +
    +VirtualSky.prototype.drawStars = function(){
    +
    +	if(!this.showstars && !this.showstarlabels) return this;
    +	var mag,i,p,d,atmos,fovf;
    +	var c = this.ctx;
    +	c.beginPath();
    +	c.fillStyle = this.col.stars;
    +	this.az_off = (this.az_off+360)%360;
    +	atmos = this.hasAtmos();
    +	fovf = Math.sqrt(30/this.fov);
    +	var f = 1;
    +	if(this.negative) f *= 1.4;
    +	if(typeof this.scalestars==="number" && this.scalestars!=1) f *= this.scalestars;
    +	if(this.projection.id==="gnomic") f *= fovf;
    +
    +	for(i = 0; i < this.stars.length; i++){
    +		if(this.stars[i][1] < this.magnitude){
    +			mag = this.stars[i][1];
    +			p = this.radec2xy(this.stars[i][2], this.stars[i][3]);
    +			if(this.isVisible(p.el) && !isNaN(p.x) && !this.isPointBad(p)){
    +				d = 0.8*Math.max(3-mag/2.1, 0.5);
    +				// Modify the 'size' of the star by how close to the horizon it is
    +				// i.e. smaller when closer to the horizon
    +				if(atmos) d *= Math.exp(-(90-p.el)*0.01);
    +				d *= f;
    +				c.moveTo(p.x+d,p.y);
    +				if(this.showstars) c.arc(p.x,p.y,d,0,Math.PI*2,true);
    +				if(this.showstarlabels && this.starnames[this.stars[i][0]]) this.drawLabel(p.x,p.y,d,"",this.htmlDecode(this.starnames[this.stars[i][0]]));
    +			}
    +		}
    +	}
    +	c.fill();
    +
    +	return this;
    +};
    +
    +VirtualSky.prototype.hasAtmos = function(){
    +	return (typeof this.projection.atmos==="boolean") ? (this.gradient ? this.projection.atmos : this.gradient) : this.gradient;
    +};
    +
    +VirtualSky.prototype.hasGradient = function(){
    +	return (this.hasAtmos() && !this.fullsky && !this.negative) ? true : false;
    +};
    +
    +// When provided with an array of Julian dates, ra, dec, and magnitude this will interpolate to the nearest
    +// data = [jd_1, ra_1, dec_1, mag_1, jd_2, ra_2, dec_2, mag_2....]
    +VirtualSky.prototype.interpolate = function(jd,data){
    +	var mindt = jd;	// Arbitrary starting value in days
    +	var mini = 0;	// index where we find the minimum
    +	for(var i = 0 ; i < data.length ; i+=4){
    +		// Find the nearest point to now
    +		var dt = (jd-data[i]);
    +		if(Math.abs(dt) < Math.abs(mindt)){ mindt = dt; mini = i; }
    +	}
    +	var dra,ddec,dmag,pos_2,pos_1,fract;
    +	if(mindt >= 0){
    +		pos_2 = mini+1+4;
    +		pos_1 = mini+1;
    +		fract = mindt/Math.abs(data[pos_2-1]-data[pos_1-1]);
    +	}else{
    +		pos_2 = mini+1;
    +		pos_1 = mini+1-4;
    +		fract = (1+(mindt)/Math.abs(data[pos_2-1]-data[pos_1-1]));
    +	}
    +	// We don't want to attempt to find positions beyond the edges of the array
    +	if(pos_2 > data.length || pos_1 < 0){
    +		dra = data[mini+1];
    +		ddec = data[mini+2];
    +		dmag = data[mini+3];
    +	}else{
    +		dra = (Math.abs(data[pos_2]-data[pos_1]) > 180) ? (data[pos_1]+(data[pos_2]+360-data[pos_1])*fract)%360 : (data[pos_1]+(data[pos_2]-data[pos_1])*fract)%360;
    +		ddec = data[pos_1+1]+(data[pos_2+1]-data[pos_1+1])*fract;
    +		dmag = data[pos_1+2]+(data[pos_2+2]-data[pos_1+2])*fract;
    +	}
    +	return { ra: dra, dec:ddec, mag:dmag};
    +};
    +VirtualSky.prototype.drawPlanets = function(){
    +
    +	if(!this.showplanets && !this.showplanetlabels && !this.showorbits) return this;
    +	if(!this.planets || this.planets.length <= 0) return this;
    +	var ra,dec,mag,pos,p;
    +	var c = this.ctx;
    +	var oldjd = this.jd;
    +	this.jd = this.times.JD;
    +
    +	var colour = this.col.grey;
    +	var maxl = this.maxLine();
    +	this.lookup.planet = [];
    +	for(p = 0 ; p < this.planets.length ; p++){
    +		// We'll allow 2 formats here:
    +		// [Planet name,colour,ra,dec,mag] or [Planet name,colour,[jd_1, ra_1, dec_1, mag_1, jd_2, ra_2, dec_2, mag_2....]]
    +		if(!this.planets[p]) continue;
    +		if(this.planets[p].length == 3){
    +			// Find nearest JD
    +			if(this.planets[p][2].length%4 == 0){
    +				if(this.jd > this.planets[p][2][0] && this.jd < this.planets[p][2][(this.planets[p][2].length-4)]){
    +					var interp = this.interpolate(this.jd,this.planets[p][2]);
    +					ra = interp.ra;
    +					dec = interp.dec;
    +					mag = interp.mag;
    +				}else{
    +					continue;	// We don't have data for this planet so skip to the next
    +				}
    +			}
    +		}else{
    +			ra = this.planets[p][2];
    +			dec = this.planets[p][3];
    +		}
    +		this.lookup.planet.push({'ra':ra*this.d2r,'dec':dec*this.d2r,'label':(this.lang.planets ? this.lang.planets[this.planets[p][0]] : "?")});
    +		pos = this.radec2xy(ra*this.d2r,dec*this.d2r);
    +
    +		if(!this.negative) colour = this.planets[p][1];
    +		if(typeof colour==="string") c.strokeStyle = colour;
    +
    +		if((this.showplanets || this.showplanetlabels) && this.isVisible(pos.el) && mag < this.magnitude && !this.isPointBad(pos)){
    +			var d = 0;
    +			if(mag !== undefined){
    +				d = 0.8*Math.max(3-mag/2, 0.5);
    +				if(this.hasAtmos()) d *= Math.exp(-((90-pos.el)*this.d2r)*0.6);
    +			}
    +			if(d < 1.5) d = 1.5;
    +			this.drawPlanet(pos.x,pos.y,d,colour,this.planets[p][0]);
    +		}
    +
    +		if(this.showorbits && mag < this.magnitude){
    +			c.beginPath();
    +			c.lineWidth = 0.5;
    +			this.setFont();
    +			c.lineWidth = 1;
    +			var previous = {x:-1,y:-1,el:-1};
    +			for(i = 0 ; i < this.planets[p][2].length-4 ; i+=4){
    +				var point = this.radec2xy(this.planets[p][2][i+1]*this.d2r, this.planets[p][2][i+2]*this.d2r);
    +				if(previous.x > 0 && previous.y > 0 && this.isVisible(point.el)){
    +					c.moveTo(previous.x,previous.y);
    +					// Basic error checking: points behind us often have very long lines so we'll zap them
    +					if(Math.abs(point.x-previous.x) < maxl){
    +						c.lineTo(point.x,point.y);
    +					}
    +				}
    +				previous = point;
    +			}
    +			c.stroke();
    +		}
    +	}
    +
    +	// Sun & Moon
    +	if(this.showplanets || this.showplanetlabels){
    +
    +		// Only recalculate the Moon's ecliptic position if the time has changed
    +		if(oldjd != this.jd){
    +			p = this.moonPos(this.jd);
    +			this.moon = p.moon;
    +			this.sun = p.sun;
    +		}
    +		// Draw the Sun
    +		if(this.sun) {
    +			pos = this.ecliptic2xy(this.sun.lon*this.d2r,this.sun.lat*this.d2r,this.times.LST);
    +			if(this.isVisible(pos.el) && !this.isPointBad(pos)){
    +				this.drawPlanet(pos.x,pos.y,5,this.col.sun,"sun");
    +				this.lookup.sun = [this.ecliptic2radec(this.sun.lon*this.d2r,this.sun.lat*this.d2r,this.times.LST)];
    +				this.lookup.sun[0].label = this.lang.sun;
    +			}
    +		}
    +		// Draw Moon last as it is closest
    +		if(this.moon) {
    +			pos = this.ecliptic2xy(this.moon.lon*this.d2r,this.moon.lat*this.d2r,this.times.LST);
    +			if(this.isVisible(pos.el) && !this.isPointBad(pos)){
    +				this.drawPlanet(pos.x,pos.y,5,this.col.moon,"moon");
    +				this.lookup.moon = [this.ecliptic2radec(this.moon.lon*this.d2r,this.moon.lat*this.d2r,this.times.LST)];
    +				this.lookup.moon[0].label = this.lang.moon;
    +			}
    +		}
    +
    +	}
    +	return this;
    +};
    +VirtualSky.prototype.drawPlanet = function(x,y,d,colour,label){
    +	var c = this.ctx;
    +	c.beginPath();
    +	c.fillStyle = colour;
    +	c.strokeStyle = colour;
    +	c.moveTo(x+d,y+d);
    +	if(this.showplanets) c.arc(x,y,d,0,Math.PI*2,true);
    +	label = this.getPhrase('planets',label);
    +	if(this.showplanetlabels) this.drawLabel(x,y,d,colour,label);
    +	c.fill();
    +	return this;
    +};
    +VirtualSky.prototype.drawText = function(txt,x,y){
    +	this.ctx.beginPath();
    +	this.ctx.fillText(txt,x,y);
    +	return this.ctx.measureText(txt).width;
    +};
    +// Helper function. You'll need to wrap it with a this.ctx.beginPath() and a this.ctx.fill();
    +VirtualSky.prototype.drawLabel = function(x,y,d,colour,label){
    +	if(label===undefined) return this;
    +	var c = this.ctx;
    +	if(colour.length > 0) c.fillStyle = colour;
    +	c.lineWidth = 1.5;
    +	var xoff = d;
    +	if((this.polartype) && c.measureText) xoff = -c.measureText(label).width-3;
    +	if((this.polartype) && x < this.wide/2) xoff = d;
    +	c.fillText(label,x+xoff,y-(d+2));
    +	return this;
    +};
    +VirtualSky.prototype.drawConstellationLines = function(colour){
    +	if(!(this.constellation.lines || this.constellation.labels)) return this;
    +	if(!colour) colour = this.col.constellation;
    +	var x = this.ctx;
    +	x.beginPath();
    +	x.strokeStyle = colour;
    +	x.fillStyle = colour;
    +	x.lineWidth = (this.constellation.lineWidth || 0.75);
    +	var fontsize = this.fontsize();
    +	this.setFont();
    +	if(typeof this.lines!=="object") return this;
    +	var pos,posa,posb,a,b,l,idx1,idx2,s;
    +	var maxl = this.maxLine();
    +	for(var c = 0; c < this.lines.length; c++){
    +		if(this.constellation.lines){
    +			for(l = 3; l < this.lines[c].length; l+=2){
    +				a = -1;
    +				b = -1;
    +				idx1 = ''+this.lines[c][l]+'';
    +				idx2 = ''+this.lines[c][l+1]+'';
    +				if(!this.hipparcos[idx1]){
    +					for(s = 0; s < this.stars.length; s++){
    +						if(this.stars[s][0] == this.lines[c][l]){
    +							this.hipparcos[idx1] = s;
    +							break;
    +						}
    +					}
    +				}
    +				if(!this.hipparcos[idx2]){
    +					for(s = 0; s < this.stars.length; s++){
    +						if(this.stars[s][0] == this.lines[c][l+1]){
    +							this.hipparcos[idx2] = s;
    +							break;
    +						}
    +					}
    +				}
    +				a = this.hipparcos[idx1];
    +				b = this.hipparcos[idx2];
    +				if(a >= 0 && b >= 0 && a < this.stars.length && b < this.stars.length){
    +					posa = this.radec2xy(this.stars[a][2], this.stars[a][3]);
    +					posb = this.radec2xy(this.stars[b][2], this.stars[b][3]);
    +					if(this.isVisible(posa.el) && this.isVisible(posb.el)){
    +						if(!this.isPointBad(posa) && !this.isPointBad(posb)){
    +							// Basic error checking: constellations behind us often have very long lines so we'll zap them
    +							if(Math.abs(posa.x-posb.x) < maxl && Math.abs(posa.y-posb.y) < maxl){
    +								x.moveTo(posa.x,posa.y);
    +								x.lineTo(posb.x,posb.y);
    +							}
    +						}
    +					}
    +				}
    +			}
    +		}
    +
    +		if(this.constellation.labels){
    +			pos = this.radec2xy(this.lines[c][1]*this.d2r,this.lines[c][2]*this.d2r);
    +			if(this.isVisible(pos.el)){
    +				var label = this.getPhrase('constellations',this.lines[c][0]);
    +				var xoff = (x.measureText) ? -x.measureText(label).width/2 : 0;
    +				x.fillText(label,pos.x+xoff,pos.y-fontsize/2);
    +				x.fill();
    +			}
    +		}
    +	}
    +	x.stroke();
    +	return this;
    +};
    +
    +// Draw the boundaries of constellations
    +// Input: colour (e.g. "rgb(255,255,0)")
    +// We should have all the boundary points stored in this.boundaries. As many of the constellations
    +// will share boundaries we don't want to bother drawing lines that we've already done so we will
    +// keep a record of the lines we've drawn as we go. As some segments may be large on the sky we will
    +// interpolate a few points between so that boundaries follow the curvature of the projection better.
    +// As the boundaries are in FK1 we will calculate the J2000 positions once and keep them cached as
    +// this speeds up the re-drawing as the user moves the sky. We assume that the user's session << time
    +// between epochs.
    +VirtualSky.prototype.drawConstellationBoundaries = function(colour){
    +	if(!this.constellation.boundaries) return this;
    +	if(!colour) colour = this.col.constellationboundary;
    +	this.ctx.beginPath();
    +	this.ctx.strokeStyle = colour;
    +	this.ctx.fillStyle = colour;
    +	this.ctx.lineWidth = (this.constellation.boundaryWidth || 0.75);
    +	this.ctx.lineCap = "round";
    +	if(typeof this.boundaries!=="object") return this;
    +	var posa,posb,a,b,l,c,d,atob,btoa,move,i,j,ra,dc,dra,ddc,points;
    +	// Keys defining a line in both directions
    +	atob = "";
    +	btoa = "";
    +	var n = 5;
    +	var maxl = this.maxLine(5);
    +	// Create a holder for the constellation boundary points i.e. a cache of position calculations
    +	if(!this.constellation.bpts) this.constellation.bpts = new Array(this.boundaries.length);
    +	// We'll record which boundary lines we've already processed
    +	var cbdone = [];
    +	for(c = 0; c < this.boundaries.length; c++){
    +		if(typeof this.boundaries!=="string" && c < this.boundaries.length){
    +
    +			if(this.constellation.bpts[c]){
    +				// Use the old array
    +				points = this.constellation.bpts[c];
    +			}else{
    +				// Create a new array of points
    +				points = [];
    +				for(l = 1; l < this.boundaries[c].length; l+=2){
    +					b = [this.boundaries[c][l],this.boundaries[c][l+1]];
    +					if(a){
    +						atob = a[0]+','+a[1]+'-'+b[0]+','+b[1];
    +						btoa = b[0]+','+b[1]+'-'+a[0]+','+a[1];
    +					}
    +					if(l > 1){
    +						move = (cbdone[atob] || cbdone[btoa]);
    +						if(typeof move==="undefined") move = true;
    +						ra = (b[0]-a[0])%360;
    +						if(ra > 180) ra = ra-360;
    +						if(ra < -180) ra = ra+360;
    +						dc = (b[1]-a[1]);
    +
    +						// If we've already done this line we'll only calculate
    +						// two points on the line otherwise we'll do 5
    +						n = (move) ? 5 : 2;
    +						if(ra/2 > n) n = parseInt(ra);
    +						if(dc/2 > n) n = parseInt(dc);
    +
    +						dra = ra/n;
    +						ddc = dc/n;
    +
    +						for(i = 1; i <= n; i++){
    +							ra = a[0]+(i*dra);
    +							if(ra < 0) ra += 360;
    +							dc = a[1]+(i*ddc);
    +							// Convert to J2000
    +							d = this.fk1tofk5(ra*this.d2r,dc*this.d2r);
    +							points.push([d[0],d[1],move]);
    +						}
    +					}
    +					// Mark this line as drawn
    +					cbdone[atob] = true;
    +					cbdone[btoa] = true;
    +					a = b;
    +				}
    +				this.constellation.bpts[c] = points;
    +			}
    +			posa = null;
    +			// Now loop over joining the points
    +			for(i = 0; i <= points.length; i++){
    +				j = (i == points.length) ? 0 : i;
    +				posb = this.radec2xy(points[j][0],points[j][1]);
    +				if(posa && this.isVisible(posa.el) && this.isVisible(posb.el) && points[j][2]){
    +					if(!this.isPointBad(posa) && !this.isPointBad(posb)){
    +						// Basic error checking: constellations behind us often have very long lines so we'll zap them
    +						if(Math.abs(posa.x-posb.x) < maxl && Math.abs(posa.y-posb.y) < maxl){
    +							this.ctx.moveTo(posa.x,posa.y);
    +							this.ctx.lineTo(posb.x,posb.y);
    +						}
    +					}
    +				}
    +				posa = posb;
    +			}
    +		}
    +	}
    +	cbdone = [];
    +	this.ctx.stroke();
    +	return this;
    +};
    +VirtualSky.prototype.drawGalaxy = function(colour){
    +	if(!this.galaxy || !this.showgalaxy) return this;
    +	if(!colour) colour = this.col.galaxy;
    +	this.ctx.beginPath();
    +	this.ctx.strokeStyle = colour;
    +	this.ctx.fillStyle = colour;
    +	this.ctx.lineWidth = (this.gal.lineWidth || 0.75);
    +	this.ctx.lineJoin = "round";
    +	var p,pa,pb,i,c,maxl,dx,dy;
    +	maxl = this.maxLine(5);
    +
    +	for(c = 0; c < this.galaxy.length; c++){
    +
    +		// We will convert all the galaxy outline coordinates to radians
    +		if(!this.gal.processed){
    +			for(i = 1; i < this.galaxy[c].length; i++) this.galaxy[c][i] *= this.d2r;
    +		}
    +
    +		// Get a copy of the current shape
    +		p = this.galaxy[c].slice(0);
    +
    +		// Get the colour (first element)
    +		p.shift();
    +		// Set the initial point to null
    +		pa = null;
    +
    +		// Now loop over joining the points
    +		for(i = 0; i < p.length; i+=2){
    +			pb = this.radec2xy(p[i], p[i+1]);
    +			if(i==0) this.ctx.moveTo(pb.x,pb.y);
    +			else{
    +				dx = Math.abs(pa.x-pb.x);
    +				dy = Math.abs(pa.y-pb.y);
    +				if(!isNaN(dx) && !isNaN(dy)){
    +					// Basic error checking: if the line is very long we need to normalize to other side of sky
    +					if(dx >= maxl || dy >= maxl) this.ctx.moveTo(pb.x,pb.y);
    +					this.ctx.lineTo(pb.x,pb.y);
    +				}else{
    +					this.ctx.moveTo(pb.x,pb.y);
    +				}
    +			}
    +			pa = pb;
    +		}
    +	}
    +	// We've converted the galaxy to radians
    +	this.gal.processed = true;
    +	this.ctx.stroke();
    +	return this;
    +};
    +VirtualSky.prototype.drawMeteorShowers = function(colour){
    +	if(!this.meteorshowers || typeof this.showers==="string") return this;
    +	if(!colour) colour = this.col.showers;
    +	var pos, label, xoff, c, d, p, start, end, dra, ddc, f;
    +	c = this.ctx;
    +	c.beginPath();
    +	c.strokeStyle = colour;
    +	c.fillStyle = colour;
    +	c.lineWidth = (this.grid.lineWidth || 0.75);
    +	var fs = this.fontsize();
    +	this.setFont();
    +	var y = this.clock.getFullYear();
    +	this.lookup.meteorshower = [];
    +	for(var s in this.showers){
    +		if(this.showers[s]){
    +			d = this.showers[s].date;
    +			p = this.showers[s].pos;
    +			start = new Date(y,d[0][0]-1,d[0][1]);
    +			end = new Date(y,d[1][0]-1,d[1][1]);
    +			if(start > end && this.clock < start) start = new Date(y-1,d[0][0]-1,d[0][1]);
    +			if(this.clock > start && this.clock < end){
    +				dra = (p[1][0]-p[0][0]);
    +				ddc = (p[1][1]-p[0][1]);
    +				f = (this.clock-start)/(end-start);
    +				pos = this.radec2xy((this.showers[s].pos[0][0]+(dra*f))*this.d2r,(this.showers[s].pos[0][1]+(ddc*f))*this.d2r);
    +
    +				if(this.isVisible(pos.el)){
    +					label = this.htmlDecode(this.showers[s].name);
    +					xoff = (c.measureText) ? -c.measureText(label).width/2 : 0;
    +					c.moveTo(pos.x+2,pos.y);
    +					c.arc(pos.x,pos.y,2,0,Math.PI*2,true);
    +					c.fillText(label,pos.x+xoff,pos.y-fs/2);
    +					this.lookup.meteorshower.push({'ra':(this.showers[s].pos[0][0]+(dra*f))*this.d2r,'dec':(this.showers[s].pos[0][1]+(ddc*f))*this.d2r,'label':label});
    +				}
    +			}
    +		}
    +	}
    +	c.fill();
    +	return this;
    +};
    +
    +VirtualSky.prototype.drawEcliptic = function(colour){
    +	if(!this.ecliptic) return this;
    +	if(!colour || typeof colour!="string") colour = this.col.ec;
    +	var c = this.ctx;
    +	var step = 2*this.d2r;
    +	c.beginPath();
    +	c.strokeStyle = colour;
    +	c.lineWidth = 3;
    +	var maxl = this.maxLine();
    +
    +	var old = {x:-1,y:-1,moved:false};
    +	for(var a = 0 ; a < Math.PI*2 ; a += step) old = joinpoint(this,"ec",a,0,old,maxl);
    +
    +	c.stroke();
    +	return this;
    +};
    +
    +VirtualSky.prototype.drawMeridian = function(colour){
    +	if(!this.meridian) return this;
    +	if(!colour || typeof colour!="string") colour = this.col.meridian;
    +	var c = this.ctx;
    +	var a, b;
    +	var minb = 0;
    +	var maxb = (typeof this.projection.maxb==="number") ? this.projection.maxb*this.d2r : Math.PI/2;
    +	var step = 2*this.d2r;
    +	var maxl = this.maxLine();
    +	c.beginPath();
    +	c.strokeStyle = colour;
    +	c.lineWidth = 2;
    +
    +	var old = {x:-1,y:-1,moved:false};
    +	for(b = minb, a = 0; b <= maxb ; b+= step) old = joinpoint(this,"az",Math.PI,b,old,maxl);
    +	for(b = maxb, a = 0; b >= minb ; b-= step) old = joinpoint(this,"az",0,b,old,maxl);
    +
    +	c.stroke();
    +	return this;
    +};
    +
    +// type can be "az" or "eq"
    +VirtualSky.prototype.drawGridlines = function(type,step,colour){
    +	if(!type || !this.grid[type]) return this;
    +	if(typeof colour!=="string") colour = this.col[type];
    +	if(typeof step!=="number") step = this.grid.step;
    +
    +	var maxb,minb,maxl,old,a,b,c,oldx,oldy,bstep;
    +	c = this.ctx;
    +	oldx = 0;
    +	oldy = 0;
    +	c.beginPath();
    +	c.strokeStyle = colour;
    +	c.lineWidth = (this.grid.lineWidth || 1);
    +	bstep = 2;
    +	if(type=="az"){
    +		maxb = (typeof this.projection.maxb==="number") ? this.projection.maxb : 90-bstep;
    +		minb = 0;
    +	}else{
    +		maxb = 90-bstep;
    +		minb = -maxb;
    +	}
    +	maxl = this.maxLine(5);
    +	old = {x:-1,y:-1,moved:false};
    +	step *= this.d2r;
    +	bstep *= this.d2r;
    +	minb *= this.d2r;
    +	maxb *= this.d2r;
    +	// Draw grid lines in elevation/declination/latitude
    +	for(a = 0 ; a < Math.PI*2 ; a += step){
    +		old.moved = false;
    +		for(b = minb; b <= maxb ; b+= bstep) old = joinpoint(this,type,a,b,old,maxl);
    +	}
    +	c.stroke();
    +	c.beginPath();
    +	if(type=="az"){
    +		minb = 0;
    +		maxb = 90-bstep*this.r2d;
    +	}else{
    +		minb = -90+step*this.r2d;
    +		maxb = 90;
    +	}
    +	minb *= this.d2r;
    +	maxb *= this.d2r;
    +	old = {x:-1,y:-1,moved:false};
    +	// Draw grid lines in azimuth/RA/longitude
    +	for(b = minb; b < maxb ; b += step){
    +		old.moved = false;
    +		for(a = 0 ; a <= 2*Math.PI ; a += bstep) old = joinpoint(this,type,a,b,old,maxl);
    +	}
    +	c.stroke();
    +	return this;
    +};
    +
    +VirtualSky.prototype.drawCardinalPoints = function(){
    +	if(!this.cardinalpoints) return this;
    +	var i,x,y,pos,ang,f,m,r;
    +	var azs = new Array(0,90,180,270);
    +	var d = [this.getPhrase('N'),this.getPhrase('E'),this.getPhrase('S'),this.getPhrase('W')];
    +	var pt = 15;
    +	var c = this.ctx;
    +	c.beginPath();
    +	c.fillStyle = this.col.cardinal;
    +
    +	// ALLSKY CHANGED: use new cardinalpoints_fontsize if set, otherwise fontsize().
    +	var saved_fontsize = this.fontsize();
    +	var fontsize = this.cardinalpoints_fntsze ? parseInt(this.cardinalpoints_fntsze) : saved_fontsize;
    +	// This is a hack.  fillText() uses the stored original fontsize, so we have to
    +	// temporarily replace it, draw the points, then replace it.
    +	if (fontsize != saved_fontsize)
    +		this.ctx.font = fontsize+"px Helvetica";
    +
    +	for(i = 0 ; i < azs.length ; i++){
    +		if(c.measureText){
    +			m = c.measureText(d[i]);
    +			r = (m.width > fontsize) ? m.width/2 : fontsize/2;
    +		}else r = fontsize/2;
    +		ang = (azs[i]-this.az_off)*this.d2r;
    +		if(this.polartype){
    +			f = (this.tall/2) - r*1.5;
    +			x = -f*Math.sin(ang);
    +			y = -f*Math.cos(ang);
    +			x = isFinite(x) ? this.wide/2 + x - r : 0;
    +			y = isFinite(y) ? this.tall/2 + y + r: 0;
    +		}else{
    +			pos = this.azel2xy(ang,0,this.wide,this.tall);
    +			x = isFinite(pos.x) ? pos.x - r : 0;
    +			y = isFinite(pos.y) ? pos.y - pt/2 : 0;
    +			if(x < 0 || x > this.wide-pt) x = -r;
    +		}
    +		if(x > 0) c.fillText(d[i],x,y);
    +	}
    +	c.fill();
    +
    +	if (fontsize != saved_fontsize)
    +		this.ctx.font = saved_fontsize+"px Helvetica";
    +
    +	return this;
    +};
    +
    +// Assume decimal Ra/Dec
    +VirtualSky.prototype.highlight = function(i,colour){
    +	var p = this.pointers[i];
    +	if(this.pointers[i].ra && this.pointers[i].dec){
    +		colour = p.colour || colour || "rgba(255,0,0,1)";
    +		if(this.negative) colour = this.getNegative(colour);
    +		var pos = this.radec2xy(p.ra*this.d2r, p.dec*this.d2r);
    +		var c = this.ctx;
    +		if(this.isVisible(pos.el)){
    +			p.az = pos.az;
    +			p.el = pos.el;
    +			p.x = pos.x;
    +			p.y = pos.y;
    +			c.fillStyle = colour;
    +			c.strokeStyle = colour;
    +			c.beginPath();
    +			// Draw a square to distinguish from other objects
    +			// c.arc(p.x,p.y,p.d/2,0,2*Math.PI);
    +			c.fillRect(p.x-p.d/2,p.y-p.d/2,p.d,p.d);
    +			c.fill();
    +			this.drawLabel(p.x,p.y,p.d,colour,p.label);
    +		}
    +	}
    +	return this;
    +};
    +
    +// Function to join the dots
    +function joinpoint(s,type,a,b,old,maxl){
    +	var x,y,show,c,pos;
    +	c = s.ctx;
    +	if(type=="az") pos = s.azel2xy((a-s.az_off*s.d2r),b,s.wide,s.tall);
    +	else if(type=="eq") pos = s.radec2xy(a,b);
    +	else if(type=="ec") pos = s.ecliptic2xy(a,b,s.times.LST);
    +	else if(type=="gal") pos = s.gal2xy(a,b);
    +	x = pos.x;
    +	y = pos.y;
    +	if(type=="az") show = true;
    +	else show = ((s.isVisible(pos.el)) ? true : false);
    +	if(show && isFinite(x) && isFinite(y)){
    +		if(type=="az"){
    +			if(!old.moved || Math.sqrt(Math.pow(old.x-x,2)+Math.pow(old.y-y,2)) > s.tall/2) c.moveTo(x,y);
    +			c.lineTo(x,y);
    +			old.moved = true;
    +		}else{
    +			// If the last point on s contour is more than a canvas width away
    +			// it is probably supposed to be behind us so we won't draw a line
    +			if(!old.moved || Math.sqrt(Math.pow(old.x-x,2)+Math.pow(old.y-y,2)) > maxl){
    +				c.moveTo(x,y);
    +				old.moved = true;
    +			}else c.lineTo(x,y);
    +		}
    +		old.x = x;
    +		old.y = y;
    +	}
    +	return old;
    +}
    +
    +VirtualSky.prototype.maxLine = function(f){
    +	if(this.projection.id==="gnomic") return this.tall;
    +	if(typeof f!=="number") f = 3;
    +	return this.tall/f;
    +};
    +
    +// Expects a latitude,longitude string (comma separated)
    +VirtualSky.prototype.setGeo = function(pos){
    +	if(typeof pos!=="string") return this;
    +	pos = pos.split(',');
    +	this.setLatitude(pos[0]);
    +	this.setLongitude(pos[1]);
    +	return this;
    +};
    +
    +// Input: latitude (deg)
    +VirtualSky.prototype.setLatitude = function(l){
    +	this.latitude = {'deg':parseFloat(l),'rad':inrangeEl(parseFloat(l)*this.d2r)};
    +	// 0.7.3: this.latitude = inrangeEl(parseFloat(l)*this.d2r);
    +	return this;
    +};
    +
    +// Input: longitude (deg)
    +VirtualSky.prototype.setLongitude = function(l){
    +	this.longitude = {'deg':parseFloat(l),'rad':parseFloat(l)*this.d2r};
    +	while(this.longitude.rad <= -Math.PI) this.longitude.rad += 2*Math.PI;
    +	while(this.longitude.rad > Math.PI) this.longitude.rad -= 2*Math.PI;
    +	// 0.7.3: this.longitude = parseFloat(l)*this.d2r;
    +	// 0.7.3: while(this.longitude <= -Math.PI) this.longitude += 2*Math.PI;
    +	// 0.7.3: while(this.longitude > Math.PI) this.longitude -= 2*Math.PI;
    +	return this;
    +};
    +
    +
    +VirtualSky.prototype.toggleFullScreen = function(){
    +	if(fullScreenApi.isFullScreen()){
    +		fullScreenApi.cancelFullScreen(this.container[0]);
    +		this.fullscreen = false;
    +		this.container.removeClass('fullscreen');
    +	}else{
    +		fullScreenApi.requestFullScreen(this.container[0]);
    +		this.fullscreen = true;
    +		this.container.addClass('fullscreen');
    +	}
    +
    +	return this;
    +};
    +
    +VirtualSky.prototype.setRADec = function(r,d){
    +	return this.setRA(r).setDec(d);
    +};
    +
    +VirtualSky.prototype.setRA = function(r){
    +	this.ra_off = (r%360)*this.d2r;
    +	return this;
    +};
    +
    +VirtualSky.prototype.setDec = function(d){
    +	this.dc_off = d*this.d2r;
    +	return this;
    +};
    +
    +// Pan the view to the specified RA,Dec
    +// Inputs: RA (deg), Dec (deg), duration (seconds)
    +VirtualSky.prototype.panTo = function(ra,dec,s){
    +	if(!s) s = 1000;
    +	if(typeof ra!=="number" || typeof dec!=="number") return this;
    +	this.panning = { s: { ra:this.ra_off*this.r2d, dec:this.dc_off*this.r2d }, e: { ra: ra, dec: dec}, duration: s, start: new Date() };
    +	this.panning.dr = this.panning.e.ra-this.panning.s.ra;
    +	this.panning.dd = this.panning.e.dec-this.panning.s.dec;
    +	if(this.panning.dr > 180) this.panning.dr = -(360-this.panning.dr);
    +	if(this.panning.dr < -180) this.panning.dr = (360+this.panning.dr);
    +	return this.panStep();
    +};
    +
    +// shim layer with setTimeout fallback
    +window.requestAnimFrame = (function(){
    +	return  window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function( callback ){ window.setTimeout(callback, 1000 / 60); };
    +})();
    +
    +// Animation step for the panning
    +VirtualSky.prototype.panStep = function(){
    +	var ra,dc;
    +	var now = new Date();
    +	var t = (now - this.panning.start)/this.panning.duration;
    +	ra = this.panning.s.ra + (this.panning.dr)*(t);
    +	dc = this.panning.s.dec + (this.panning.dd)*(t);
    +
    +	// Still animating
    +	if(t < 1){
    +		// update and draw
    +		this.setRADec(ra,dc).draw("panStep<1");
    +		var _obj = this;
    +		// request new frame
    +		requestAnimFrame(function() { _obj.panStep(); });
    +	}else{
    +		// We've ended
    +		this.setRADec(this.panning.e.ra,this.panning.e.dec).draw("panStep");
    +	}
    +	return this;
    +};
    +
    +VirtualSky.prototype.liveSky = function(pos){
    +	this.islive = !this.islive;
    +	if(this.islive) interval = window.setInterval(function(sky){ sky.setClock('now', 'liveSky'); },1000,this);
    +	else{
    +		if(interval!==undefined) clearInterval(interval);
    +	}
    +	return this;
    +};
    +
    +VirtualSky.prototype.start = function(){
    +	this.islive = true;
    +	// Clear existing interval
    +	if(interval!==undefined) clearInterval(interval);
    +	interval = window.setInterval(function(sky){ sky.setClock('now', 'start'); },1000,this);
    +};
    +VirtualSky.prototype.stop = function(){
    +	this.islive = false;
    +	// Clear existing interval
    +	if(interval!==undefined) clearInterval(interval);
    +};
    +// Increment the clock by the amount specified
    +VirtualSky.prototype.advanceTime = function(by,wait){
    +	if(by===undefined){
    +		this.updateClock(new Date());
    +	}else{
    +		by = parseFloat(by);
    +		if(!wait) wait = 1000/this.fps; // ms between frames
    +		var fn = function(vs,by){ vs.setClock(by, 'advanceTime'); };
    +		clearInterval(this.interval_time);
    +		clearInterval(this.interval_calendar);
    +		this.interval_time = window.setInterval(fn,wait,this,by);
    +		// Whilst animating we'll periodically check to see if the calendar events need calling
    +		this.interval_calendar = window.setInterval(function(vs){ vs.calendarUpdate(); },1000,this);
    +	}
    +	return this;
    +};
    +// Send a Javascript Date() object and update the clock
    +VirtualSky.prototype.updateClock = function(d){
    +	this.clock = d;
    +	this.times = this.astronomicalTimes();
    +};
    +// Call any calendar-based events
    +VirtualSky.prototype.calendarUpdate = function(){
    +	for(var e = 0; e < this.calendarevents.length; e++){
    +		if(is(this.calendarevents[e],"function")) this.calendarevents[e].call(this);
    +	}
    +	return this;
    +};
    +
    +// ALLSKY ADDED "fromWhere" to know who called us and "redraw" to tell us to redraw.
    +var resetInputClock = false;
    +var setClockCalls = 0;
    +VirtualSky.prototype.setClock = function(seconds, fromWhere){
    +	if(seconds === undefined){
    +		return this;
    +	}
    +//console.log("DEBUG: setClock(" + seconds + ", " + fromWhere + ") called #" + ++setClockCalls);
    +// ALLSKY COMMENT: Telling the time to go back a week moved the time back and rotated the sky,
    +// but at the next interval (1 second) setClock("now") is called which put
    +// the time and sky back to the original position.
    +// The changes below fix that, BUT, changing the time leaves it at that time and doesn't
    +// increase by a second every second, except when resetting time.
    +	if(typeof seconds==="string"){
    +		seconds = convertTZ(seconds);
    +		if(!this.input.clock || fromWhere === "reset"){		// ALLLSKY added "|| fromWhere..."
    +//console.log("@@@@ NOT");
    +			if(seconds==="now") this.updateClock(new Date());
    +			else this.updateClock(new Date(seconds));
    +			if (fromWhere === "reset" && resetInputClock) {		// ALLSKY ADDED if statement
    +				this.input.clock = undefined;
    +				resetInputClock = false;
    +			}
    +		}else{
    +//console.log("@@@@ ELSE, input.clock=" + this.input.clock);
    +			this.updateClock((typeof this.input.clock==="string") ? this.input.clock.replace(/%20/g,' ') : this.input.clock);
    +			if(typeof this.clock==="string") this.updateClock(new Date(this.clock));
    +		}
    +	}else if(typeof seconds==="object"){
    +		this.updateClock(seconds);
    +	}else{
    +		var x = new Date(this.clock.getTime() + seconds*1000);
    +		this.updateClock(x);
    +		this.input.clock = x;	 // ALLSKY ADDED; keep track of new time
    +		resetInputClock = true;	 // ALLSKY ADDED
    +	}
    +	// ALLSKY ADDED "if" so it doesn't redraw when the overlay is hidden.
    +	if (this.wide || this.tall) this.draw("setClock");
    +	return this;
    +};
    +VirtualSky.prototype.toggleAtmosphere = function(){ this.gradient = !this.gradient; this.draw("Atmosphere"); return this; };
    +VirtualSky.prototype.toggleStars = function(){ this.showstars = !this.showstars; this.draw("Stars"); return this; };
    +VirtualSky.prototype.toggleStarLabels = function(){ this.showstarlabels = !this.showstarlabels; this.draw("StarLabels"); return this; };
    +VirtualSky.prototype.toggleNegative = function(){ this.negative = !this.negative; this.col = this.colours[(this.negative ? "negative" : "normal")]; this.draw("Negative"); return this; };
    +VirtualSky.prototype.toggleConstellationLines = function(){ this.constellation.lines = !this.constellation.lines; this.checkLoaded(); this.draw("ConstellationLines"); return this; };
    +VirtualSky.prototype.toggleConstellationBoundaries = function(){ this.constellation.boundaries = !this.constellation.boundaries; this.checkLoaded(); this.draw("ConstellationBoundaries"); return this; };
    +VirtualSky.prototype.toggleConstellationLabels = function(){ this.constellation.labels = !this.constellation.labels; this.checkLoaded(); this.draw("ConstellationLabels"); return this; };
    +VirtualSky.prototype.toggleCardinalPoints = function(){ this.cardinalpoints = !this.cardinalpoints; this.draw("CardinalPoints"); return this; };
    +VirtualSky.prototype.toggleGridlinesAzimuthal = function(){ this.grid.az = !this.grid.az; this.draw("GridlinesAzimuthal"); return this; };
    +VirtualSky.prototype.toggleGridlinesEquatorial = function(){ this.grid.eq = !this.grid.eq; this.draw("GridlinesEquatorial"); return this; };
    +VirtualSky.prototype.toggleGridlinesGalactic = function(){ this.grid.gal = !this.grid.gal; this.draw("GridlinesGalactic"); return this; };
    +VirtualSky.prototype.toggleEcliptic = function(){ this.ecliptic = !this.ecliptic; this.draw("Ecliptic"); return this; };
    +VirtualSky.prototype.toggleMeridian = function(){ this.meridian = !this.meridian; this.draw("Meridian"); return this; };
    +VirtualSky.prototype.toggleGround = function(){ this.ground = !this.ground; this.draw("Ground"); return this; };
    +VirtualSky.prototype.toggleGalaxy = function(){ this.showgalaxy = !this.showgalaxy; this.checkLoaded(); this.draw("Galaxy"); return this; };
    +VirtualSky.prototype.toggleMeteorShowers = function(){ this.meteorshowers = !this.meteorshowers; this.checkLoaded(); this.draw("MeteorShowers"); return this; };
    +VirtualSky.prototype.togglePlanetHints = function(){ this.showplanets = !this.showplanets; this.draw("PlanetHints"); return this; };
    +VirtualSky.prototype.togglePlanetLabels = function(){ this.showplanetlabels = !this.showplanetlabels; this.draw("PlanetLabels"); return this; };
    +VirtualSky.prototype.toggleOrbits = function(){ this.showorbits = !this.showorbits; this.draw("Orbits"); return this; };
    +VirtualSky.prototype.toggleAzimuthMove = function(az){
    +	if(this.az_step===0){
    +		this.az_step = (typeof az==="number") ? az : -1;
    +		this.moveIt();
    +	}else{
    +		this.az_step = 0;
    +		if(this.timer_az!==undefined) clearTimeout(this.timer_az);
    +	}
    +	return this;
    +};
    +VirtualSky.prototype.addPointer = function(input){
    +	// Check if we've already added this
    +	var style,url,img,label,credit;
    +	var matched = -1;
    +	var p;
    +	for(var i = 0 ; i < this.pointers.length ; i++){
    +		if(this.pointers[i].ra == input.ra && this.pointers[i].dec == input.dec && this.pointers[i].label == input.label) matched = i;
    +	}
    +	// Hasn't been added already
    +	if(matched < 0){
    +		input.ra *= 1;	// Correct for a bug
    +		input.dec *= 1;
    +		i = this.pointers.length;
    +		p = input;
    +		p.d = is(p.d, "number")?p.d:5;
    +		if(typeof p.html !== "string"){
    +			style = p.style || "width:128px;height:128px;";
    +			url = p.url || "http://server1.wikisky.org/v2?ra="+(p.ra/15)+"&de="+(p.dec)+"&zoom=6&img_source=DSS2";
    +			img = p.img || 'http://server7.sky-map.org/imgcut?survey=DSS2&w=128&h=128&ra='+(p.ra/15)+'&de='+p.dec+'&angle=0.25&output=PNG';
    +			label = p.credit || "View in Wikisky in new tab";	// ALLSKY ADDED "in new tab"
    +			credit = p.credit || "DSS2/Wikisky";
    +			// ALLSKY ADDED "target"
    +			p.html =  p.html ||
    +				'<div class="virtualsky_infocredit">'+
    +					'<a href="'+url+'" target="_blank" style="color: white;">'+credit+'</a>'+
    +				'</div>'+
    +				'<a href="'+url+'" target="_blank" style="display:block;'+style+'">'+
    +					'<img src="'+img+'" style="border:0px;'+style+'" title="'+label+'" />'+
    +				'</a>';
    +		}
    +		this.pointers[i] = p;
    +	}
    +	return (this.pointers.length);
    +};
    +VirtualSky.prototype.changeAzimuth = function(inc){
    +	this.az_off += (typeof inc==="number") ? inc : 5;
    +	this.draw("changeAzimuth");
    +	return this;
    +};
    +VirtualSky.prototype.moveIt = function(){
    +	// Send 'this' context to the setTimeout function so we can redraw
    +	this.timer_az = window.setTimeout(function(mysky){ mysky.az_off += mysky.az_step; mysky.draw("moveIt"); mysky.moveIt(); },100,this);
    +	return this;
    +};
    +VirtualSky.prototype.spinIt = function(tick,wait){
    +	if(typeof tick==="number") this.spin = (tick == 0) ? 0 : (this.spin+tick);
    +	else{
    +		var t = 1.0/this.fps;
    +		var s = 2;
    +		// this.spin is the number of seconds to update the clock by
    +		if(this.spin == 0) this.spin = (tick == "up") ? t : -t;
    +		else{
    +			if(Math.abs(this.spin) < 1) s *= 2;
    +			if(this.spin > 0) this.spin = (tick == "up") ? (this.spin*s) : (this.spin/s);
    +			else if(this.spin < 0) this.spin = (tick == "up") ? (this.spin/s) : (this.spin*s);
    +			if(this.spin < t && this.spin > -t) this.spin = 0;
    +		}
    +	}
    +	if(this.interval_time!==undefined)
    +		clearInterval(this.interval_time);
    +	if(this.spin != 0)
    +		this.advanceTime(this.spin,wait);
    +	return this;
    +};
    +VirtualSky.prototype.getOffset = function(el){
    +	var _x = 0;
    +	var _y = 0;
    +	while( el && !isNaN( el.offsetLeft ) && !isNaN( el.offsetTop ) ) {
    +		_x += el.offsetLeft - el.scrollLeft;
    +		_y += el.offsetTop - el.scrollTop;
    +		el = el.parentNode;
    +	}
    +	return { top: _y, left: _x };
    +};
    +VirtualSky.prototype.getJD = function(clock) {
    +	// The Julian Date of the Unix Time epoch is 2440587.5
    +	if(!clock) clock = this.clock;
    +	return ( clock.getTime() / 86400000.0 ) + 2440587.5;
    +};
    +VirtualSky.prototype.getNegative = function(colour){
    +	var end = (colour.indexOf("rgb") == 0) ? (colour.lastIndexOf(")")) :  0;
    +	if(end == 0) return colour;
    +	var rgb = colour.substring(colour.indexOf("(")+1,end).split(",");
    +	return (rgb.length==3) ? ('rgb('+(255-rgb[0])+','+(255-rgb[1])+','+(255-rgb[2])+')') : ('rgba('+(255-rgb[0])+','+(255-rgb[1])+','+(255-rgb[2])+','+(rgb[3])+')');
    +};
    +// Calculate the Great Circle angular distance (in radians) between two points defined by d1,l1 and d2,l2
    +VirtualSky.prototype.greatCircle = function(l1,d1,l2,d2){
    +	return Math.acos(Math.cos(d1)*Math.cos(d2)*Math.cos(l1-l2)+Math.sin(d1)*Math.sin(d2));
    +};
    +
    +// Bind events
    +VirtualSky.prototype.on = function(ev,fn){
    +	if(typeof ev!=="string" || typeof fn!=="function") return this;
    +	if(this.events[ev]) this.events[ev].push(fn);
    +	else this.events[ev] = [fn];
    +	return this;
    +};
    +VirtualSky.prototype.bind = function(ev,fn){
    +	return this.on(ev,fn);
    +};
    +// Trigger a defined event with arguments. This is meant for internal use
    +// sky.trigger("zoom",args)
    +VirtualSky.prototype.trigger = function(ev,args){
    +	if(typeof ev!=="string") return;
    +	if(typeof args!=="object") args = {};
    +	var o = [];
    +	var _obj = this;
    +	if(typeof this.events[ev]==="object")
    +		for(i = 0 ; i < this.events[ev].length ; i++)
    +			if(typeof this.events[ev][i]==="function")
    +				o.push(this.events[ev][i].call(_obj,args));
    +	if(o.length > 0) return o;
    +};
    +
    +// Some useful functions
    +function convertTZ(s){
    +	function formatHour(h){
    +		var s = (h >= 0 ? "+" : "-");
    +		h = Math.abs(h);
    +		var m = (h - Math.floor(h))*60;
    +		h = Math.floor(h);
    +		return s+(h < 10 ? "0"+h : h)+(m < 10 ? "0"+m : m);
    +	}
    +	var tzs = { A:1, ACDT:10.5, ACST:9.5, ADT:-3, AEDT:11, AEST:10, AKDT:-8, AKST:-9,
    +		AST:-4, AWST:8, B:2, BST:1, C:3, CDT:-5, CEDT:2, CEST:2, CET:1, CST:-6, CXT:7,
    +		D:4, E:5, EDT:-4, EEDT:3, EEST:3, EET:2, EST:-5, F:6, G:7, GMT:0, H:8, HAA:-3,
    +		HAC:-5, HADT:-9, HAE:-4, HAP:-7, HAR:-6, HAST:-10, HAT:-2.5, HAY:-8, HNA:-4, HNC:-6,
    +		HNE:-5, HNP:-8, HNR:-7, HNT:-3.5, HNY:-9, I:9, IST:9, JST:9, K:10, L:11,
    +		M:12, MDT:-6, MESZ:2, MEZ:1, MST:-7, N:-1, NDT:-2.5, NFT:11.5, NST:-3.5, O:-2, P:-3,
    +		PDT:-7, PST:-8, Q:-4, R:-5, S:-6, T:-7, U:-8, UTC:0, UT:0, V:-9, W:-10, WEDT:1, WEST:1,
    +		WET:0, WST:8, X:-11, Y:-12, Z:0 };
    +	// Get location of final space character
    +	var i = s.lastIndexOf(' ');
    +	// Replace the time zone with the +XXXX version
    +	if(i > 0 && tzs[s.substr(i+1)]){
    +		return s.substring(0,i)+" "+formatHour(tzs[s.substr(i+1)]);
    +	}
    +	return s;
    +}
    +
    +
    +S.virtualsky = function(placeholder,input) {
    +	if(typeof input==="object") input.container = placeholder;
    +	else {
    +		if(typeof placeholder==="string") input = { container: placeholder };
    +		else input = placeholder;
    +	}
    +	if(!input) input = {};
    +	input.plugins = S.virtualsky.plugins;
    +	return new VirtualSky(input);
    +};
    +
    +S.virtualsky.plugins = [];
    +
    +})(S);
    diff --git a/html/cgi-bin/format.py b/html/cgi-bin/format.py
    index 20f3ab258..92528766d 100644
    --- a/html/cgi-bin/format.py
    +++ b/html/cgi-bin/format.py
    @@ -9,6 +9,7 @@
     class ALLSKYFORMAT:
     
         _allSkyVariables = {}
    +    _returnString = None
     
         _config = ""
         _fields = ""
    @@ -33,13 +34,36 @@ def __init__(self):
             self._getAllSkyVariables()
     
         def _getAllSkyVariables(self):
    -        allskyVariables = {}
    -
    +        # Can't use ALLSKY_TMP since it's not defined
             scriptName = f"html{os.environ['SCRIPT_NAME']}"
             scriptFileName = os.environ["SCRIPT_FILENAME"]
             allSkyHome = scriptFileName.replace(scriptName, "")
             allSkyTmp = f"{allSkyHome}tmp"
             allSkyVariableFile = f"{allSkyTmp}/overlaydebug.txt"
    +
    +        # Don't return any data in legacy mode.
    +        # We really shouldn't even be called.
    +        if self._getSetting('overlaymethod') == 0:
    +            self._returnString = "LEGACY_MODE"
    +            print("Content-type: text/html\n")
    +            data = {
    +                "result": self._returnString,
    +                "fields": {}
    +            }
    +            print(json.dumps(data, indent = 4))
    +            return
    +
    +        if os.path.isfile(allSkyVariableFile) == False:
    +            self._returnString = "FILE_MISSING"
    +            print("Content-type: text/html\n")
    +            data = {
    +                "result": self._returnString,
    +                "missingFile": allSkyVariableFile,
    +                "fields": {}
    +            }
    +            print(json.dumps(data, indent = 4))
    +            return
    +
             with open(allSkyVariableFile, "r", encoding="ISO-8859-1") as f:
                 try:
                     for line in f:
    @@ -51,6 +75,7 @@ def _getAllSkyVariables(self):
                             self._allSkyVariables[variable] = value
                 except Exception as e:
                     raise(e)
    +        self._returnString = "OK"
     
         def _getFieldType(self, name):
             result = None
    @@ -105,7 +130,6 @@ def _getEnvironmentVariable(self, name):
             return result
     
         def _readSettings(self):
    -
             settingsFile = self._getEnvironmentVariable("SETTINGS_FILE")
             if settingsFile is None:
                 settingsFile = os.path.join(self._getEnvironmentVariable("ALLSKY_HOME"),"config","settings.json")
    @@ -114,7 +138,6 @@ def _readSettings(self):
                 self._settings = json.load(fp)
     
         def _getSetting(self, settingName):
    -
             if self._settings == None:
                 self._readSettings()
     
    @@ -185,14 +208,21 @@ def _getValue(self, format, fieldValue, variableType, label):
     
             if variableType == 'Number':
                 if format is not None and format != "":
    -                format = "{" + format + "}"
    +                if format.startswith(':'):
    +                    format = "{" + format + "}"
                     try:
                         try:
                             convertValue = int(fieldValue)
                         except ValueError:
                             convertValue = float(fieldValue)
                         try:
    -                        value = format.format(convertValue)
    +                        if format.startswith('{'):
    +                            value = format.format(convertValue)
    +                        else:
    +                            if format.startswith('%'):
    +                                value = locale.format_string(format, convertValue, grouping=True)
    +                            else:
    +                                value = convertValue
                         except Exception as err:
                             value = "??"
                     except ValueError as err:
    @@ -257,9 +287,16 @@ def createSampleData(self):
             }
             print(json.dumps(data, indent = 4))
     
    +    def getReturnString(self):
    +        return self._returnString
    +
    +
     try:
    +    sampleEngine = None
         sampleEngine = ALLSKYFORMAT()
    -    sampleEngine.createSampleData()
    +    if sampleEngine is not None and sampleEngine.getReturnString() == "OK":
    +        sampleEngine.createSampleData()
    +
     except Exception as e:
         print("Content-type: text/html\n")
         data = {
    diff --git a/html/css/modules.css b/html/css/modules.css
    index 5535be67e..4ae771fe6 100644
    --- a/html/css/modules.css
    +++ b/html/css/modules.css
    @@ -1,3 +1,6 @@
    +:root {
    +	--dark-text: #aaaaaa;
    +}
     
     .ghost {
         opacity: 0.4;
    @@ -68,21 +71,21 @@
     }
     .dark .list-group .locked .panel-heading {
         background-color: #400000 !important;
    -    color: #888888 !important;
    +    color: var(--dark-text) !important;
     }
     .dark .list-group .locked .panel-body {
         background-color: #500000 !important;
    -    color: #888888 !important;
    +    color: var(--dark-text) !important;
     } 
     .dark .panel-heading, .dark .panel-footer {
    -  color: #888888;
    +  color: var(--dark-text);
       border-color: #333333;
       background-color: #171717;
     }
     
     .dark .modal-content {
         background-color: #222222 !important;
    -    color: #888888 !important;    
    +    color: var(--dark-text) !important;    
     }
     
     .dark .modal-header {
    @@ -97,13 +100,13 @@
         color: green;
       }
     
    -  .red {
    +.red {
         color: red;
    -  }
    +}
     
     .pulse {
         animation: pulse 1s infinite ease-out;
    -  }
    +}
     
       @keyframes pulse {
         0% {
    @@ -182,4 +185,4 @@
     
     .module-error-dialog > .modal-dialog {
       width:50% !important;
    -}
    \ No newline at end of file
    +}
    diff --git a/html/css/overlay.css b/html/css/overlay.css
    index f04a9b20a..d788355b0 100644
    --- a/html/css/overlay.css
    +++ b/html/css/overlay.css
    @@ -1,3 +1,7 @@
    +:root {
    +    --dark-text: #aaaaaa;
    +}
    +
     .oe-flash {
         padding: 10px;
     }
    @@ -12,7 +16,6 @@
         margin-bottom: 3px;
     }
     
    -
     #oe-background-image {
         display: none;
     }
    @@ -50,7 +53,7 @@
     
     .dark .modal-content {
         background-color: #222222 !important;
    -    color: #888888 !important;    
    +    color: var(--dark-text) !important;    
     }
     
     .dark .modal-header {
    @@ -109,34 +112,33 @@
         background-color: #272727 !important;
     }
     
    -
     .tooltip-wrapper {
         display: inline-block; /* display: block works as well */
    -  }
    +}
       
    -  .tooltip-wrapper .btn[disabled] {
    +.tooltip-wrapper .btn[disabled] {
         /* don't let button block mouse events from reaching wrapper */
         pointer-events: none;
    -  }
    +}
       
    -  .tooltip-wrapper.disabled {
    +.tooltip-wrapper.disabled {
         /* OPTIONAL pointer-events setting above blocks cursor setting, so set it here */
         cursor: not-allowed;
    -  }
    +}
     
    -  .green {
    +.green {
         color: green;
    -  }
    +}
     
    -  .red {
    +.red {
         color: red;
    -  }
    +}
     
    -  .pulse {
    +.pulse {
         animation: pulse 1s infinite ease-out;
    -  }
    +}
     
    -  @keyframes pulse {
    +@keyframes pulse {
         25% {
           transform: scale(1.25);
         }
    @@ -149,86 +151,228 @@
         100% {
           transform: scale(1); 
         }
    -  }
    +}
     
    -  #oe-item-list-dialog-allsky, #oe-item-list-dialog-all {
    +#oe-item-list-dialog-allsky, #oe-item-list-dialog-all {
         padding-top: 20px;
    -  }
    +}
     
    -  .datatable td {
    +.datatable td {
         overflow: hidden;
    -  }
    +}
     
    -  div.dataTables_wrapper div.dataTables_paginate {
    +div.dataTables_wrapper div.dataTables_paginate {
         padding-top: 10px;
    -  }
    +}
     
    -  table.dataTable td {
    +table.dataTable td {
         padding-top: 2px;
    -  }
    +}
     
    -  .sp-replacer {
    +.sp-replacer {
         height: 2.5rem !important;
         width: 4rem !important;
    -  }
    +}
     
    -  .sp-dd {
    +.sp-dd {
         padding: 3px 2px !important;
    -  }
    +}
     
    -  .oe-overlay-editor-tab-modified:after {
    +.oe-overlay-editor-tab-modified:after {
         content: " - Modified";
         color: red;
         font-weight: 700;
    -  }
    +}
     
    -  .dark #oe-debug-dialog-form textarea {
    +.dark #oe-debug-dialog-form textarea {
         color: black;
    -  }
    -
    -  .modal-dialog {
    -    width: 770px !important;
    -  }
    +}
     
    -  .dtsp-panesContainer, .dtsp-collapseAll {
    +.dtsp-panesContainer, .dtsp-collapseAll {
         background-color: black !important;
    -  }
    +}
     
    -  .dtsp-collapseAll, .dtsp-clearAll, .dtsp-showAll  {
    +.dtsp-collapseAll, .dtsp-clearAll, .dtsp-showAll  {
         background-color: #121212 !important;
    -  } 
    +} 
     
    -  .dtsp-name, .dtb-popover-close {
    +.dtsp-name, .dtb-popover-close {
         color: black !important;
         text-transform: capitalize;
    -  }
    +}
     
    -  #formatlisttable_filter, .dtsp-searchCont {
    +#formatlisttable_filter, .dtsp-searchCont {
         display: none !important;
    -  }
    -  div.dtsp-topRow {
    +}
    +div.dtsp-topRow {
         height: 0px !important;
         min-height: 0px !important;
    -  }
    +}
     
    -  div.dtsp-searchPane div.dataTables_scrollBody {
    +div.dtsp-searchPane div.dataTables_scrollBody {
         height: 91px !important;	/* good for 3 lines */
    -  }
    -  div.dtsp-searchPane div.dtsp-topRow {
    +}
    +div.dtsp-searchPane div.dtsp-topRow {
         margin: 0px !important;
    -  }
    +}
     
    -  .dtsp-searchPane .odd, .dtsp-searchPane .even {
    +.dtsp-searchPane .odd, .dtsp-searchPane .even {
         background: #bbb !important;
    -  }
    +}
     
    -  .dataTables_length {
    +.dataTables_length {
         display: none;
    -  }
    -  #formatdialog {
    +}
    +#formatdialog {
         margin: 5px;
    -  }
    +}
     
    -  .oe-format-replace {
    +.oe-format-replace {
         margin-right: 5px;
    -  }
    +}
    +
    +.btn-lg {
    +    padding-right: 10px !important;
    +    padding-left: 10px !important;
    +}
    +
    +.panel {
    +    border: none !important;
    +}
    +
    +/** Remove after BS4 upgrade **/
    +.mt-1 {
    +    margin-top: 10px;
    +}
    +
    +.mt-2 {
    +    margin-top: 20px;
    +}
    +
    +.oe-mm-wrapper {
    +    position: fixed;
    +    top: 0px;
    +    right: 0;
    +    z-index: 1001;
    +    width: 400px;
    +    height: 100%;
    +    padding: 10px;
    +    background-color: white;    
    +    box-shadow: 0 0 15px rgba(0,0,0,0.2);
    +    transition: all 0.1s cubic-bezier(0.7,0,0.3,1);
    +    transform: translate3d(410px,0,0);
    +    z-index: 9999;
    +}
    +.oe-mm-wrapper.active {
    +    transform: translate3d(0px,0,0);
    +}
    +  
    +.dark .oe-mm-wrapper {
    +    background-color: #222;
    +}
    +
    +/* Buttons */
    +.oe-mm-trigger {
    +    position: fixed;
    +    top: 10px;
    +    right: 10px;
    +    width: 25px;
    +    height: 25px;
    +    font-size: 20px;
    +    border: none;
    +    outline: none;
    +    cursor: pointer;
    +    box-shadow: 0 0 5px rgba(0,0,0,0.2);
    +    z-index: 9999;
    +}
    +
    +.asteriskField{
    +    color: red;
    +}
    +
    +#oe-overlay-not-running {
    +    position: absolute;
    +    width: 100%;
    +    height: 100%;
    +    background-color: rgba(0,0,0,0.5); 
    +    z-index: 2; 
    +    cursor: pointer;
    +}
    +
    +#oe-overlay-not-running.big {
    +    height: 80vh;
    +}
    +
    +#oe-overlay-not-running .center {
    +    display: flex;
    +    justify-content: center;
    +    align-items: center;
    +    height: 25%;
    +    font-size: 2em;
    +}
    +
    +#oe-overlay-not-running .center-full {
    +    display: flex;
    +    justify-content: center;
    +    align-items: center;
    +    height: 100%;
    +    font-size: 2em;
    +}
    +
    +#oe-overlay-not-running .center,.center-full .center-paragraph {
    +    text-align: center;
    +    border: 3px solid green;
    +    background-color: rgba(0,0,0,0.3);
    +    padding: 15px;    
    +}
    +
    +.border-left {
    +    border-left: 1px solid #666;
    +    margin-left: 5px;
    +}
    +
    +#oe-overlay-not-running small {
    +    font-size: 50% !important;
    +}
    +
    +#oe-overlay-not-running p {
    +    margin: 0px !important;
    +}
    +
    +#oe-overlay-not-running h1 {
    +    margin-top: 10px !important;
    +    margin-bottom: 10px !important;
    +}
    +
    +#oe-overlay-disable {
    +    position: absolute;
    +    width: 100%;
    +    height: 100%;
    +    color: white;
    +    background-color: rgba(0,0,0,0.5); 
    +    z-index: 2; 
    +}
    +
    +#oe-overlay-disable .center {
    +    display: flex;
    +    justify-content: center;
    +    align-items: center;
    +    height: 25%;
    +    font-size: 2em;
    +}
    +
    +#oe-overlay-disable .center .center-paragraph {
    +    text-align: center;
    +    border: 3px solid green;
    +    background-color: rgba(0,0,0,0.3);
    +    padding: 15px;    
    +}
    +
    +#oe-overlay-disable .center .center-paragraph h2 {
    +    font-size: 2vw;
    +}
    +
    +#oe-overlay-disable .center .center-paragraph p {
    +    font-size: 1.5vw;
    +    margin-top: 10px;
    +}
    diff --git a/html/documentation/basics/Allsky.html b/html/documentation/basics/Allsky.html
    new file mode 100644
    index 000000000..1aa95015c
    --- /dev/null
    +++ b/html/documentation/basics/Allsky.html
    @@ -0,0 +1,118 @@
    +<!DOCTYPE html>
    +<html lang="en">
    +<head>
    +	<meta charset="utf-8">
    +	<meta http-equiv="X-UA-Compatible" content="IE=edge">
    +	<meta name="viewport" content="width=device-width, initial-scale=1">
    +	<script src="../js/documentation.js" type="application/javascript"></script>
    +	<link href="../css/light.css" rel="stylesheet">
    +	<link href="../bower_components/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
    +	<script src="../bower_components/jquery/dist/jquery.min.js"></script>
    +	<script src="../bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
    +	<style>
    +		#pageTitle::before {
    +			content: "Allsky Basics";
    +		}
    +	</style>
    +	<link href="../css/documentation.css" rel="stylesheet">
    +	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
    +	<title>Allsky Basics</title>
    +</head>
    +<body>
    +<div w3-include-html="/documentation/pageHeader.html" id="pageHeader"></div>
    +<div class="Layout">
    +<div class="Layout-sidebar" w3-include-html="/documentation/sidebar.html" id="sidebar"></div>
    +<div class="Layout-main markdown-body" id="mainContents">
    +
    +<p>
    +This page is for people who are new to the Allsky software and
    +describes how Allsky works, what the various pieces are, and how they fit together.
    +Although it is not necessary to know this in order to successfully use Allsky,
    +it can help while troubleshooting and many people will find it interesting.
    +If you haven't already read the
    +<a href="../miscellaneous/nomenclature.html">Nomenclature</a> page,
    +do so now as many of the terms described there are used on this page.
    +</p>
    +
    +<h4>What does Allsky do?</h4>
    +<p>
    +The Allsky software is used to control an allsky camera which takes pictures of "all the sky".
    +Once a picture is taken it is saved to disk and optionally processed and
    +uploaded to an Allsky Website
    +(either on the Pi and/or on a remote server running the Allsky Website software).
    +It can also be uploaded to a remote server not running the Allsky Website software.
    +At the end of the night, a startrails image, LINK, a keogram image,
    +and/or timelapse video can be created and uploaded.
    +</p>
    +
    +<h4>How does Allsky work?</h4>
    +<p>
    +Allsky automatically starts whenever your Pi is turned on or rebooted.
    +It can also be started and stopped manually,
    +and is restarted as needed when changing settings in the WebUI.
    +When Allsky starts it checks a few things then executes a program to take the pictures.
    +That program is either <code>capture_ZWO</code>
    +or <code>capture_RPi</code>,
    +depending on your camera type.
    +The appropriate capture program first looks at many of the settings in the WebUI
    +to determine how to expose pictures, how long to wait between pictures,
    +when to switch between daytime and nighttime, and many other things.
    +It then begins taking pictures.
    +<code>capture_ZWO</code> uses a ZWO library to control the
    +camera directly and get feedback from it.
    +<code>capture_RPi</code> calls another program called
    +<code>libcamera-still</code> to actually take a picture
    +passing it the exposure time and other settings including the name of
    +the file to save the picture to.
    +</p>
    +<p>
    +After the picture is saved, usually to a file called
    +<span class="fileName">~/allsky/tmp/image.jpg</span>,
    +the capture program calls the <code>saveImage.sh</code>
    +program to process the image while the
    +capture program checks if a night-to-day transitioned occurred;
    +if so, the capture program calls <code>endOfNight.sh</code>
    +to create startrails, keograms, and a timelapse video as specified in the settings.
    +Either way, the capture program then sleeps until it is time to take the next image.
    +</p>
    +
    +<h4>Image processing</h4>
    +<p>
    +The <code>saveImage.sh</code> program first checks if
    +the image is corrupt and then checks the settings in the WebUI to determine
    +if the image is too dark or too bright.
    +If any of those checks fail the image is deleted and
    +<code>saveImage.sh</code> exits.
    +If the image is good, it's optionally resized, cropped,
    +and/or stretched per the settings, and an optional overlay is added.
    +If the image is to be uploaded, <code>saveImage.sh</code>
    +checks if it should first be resized,
    +then calls <code>upload.sh</code> to upload it.
    +If the image will be part of a mini-timelapse,
    +<code>saveImage.sh</code> check if there are enough
    +images to create a mini-timelapse;
    +if so, it is created and optionally resized and uploaded.
    +If not, the image is added to the list of images for the next mini-timelapse. 
    +</p>
    +<p>
    +Finally, if the image is to be saved a copy of it with a name
    +<span class="fileName">image-YYYYMMDDHHMMSS.jpg</span>
    +is added the the current day's folder in  <span class="fileName">~/allsk/images</span>.
    +</p>
    +
    +<h4>End of night processing</h4>
    +<p>
    +The <code>endOfNight.sh</code> program checks the
    +settings to see whether or not a startrails, keogram,
    +and/or timelapse video is to be created; if so they are created.
    +If the items are to be uploaded they are.
    +Either way, <code>endOfNight.sh</code> then exits.
    +</p>
    +
    +
    +</div><!-- Layout-main -->
    +</div><!-- Layout -->
    +</body>
    +</html>
    +<script> includeHTML(); </script>
    diff --git a/html/documentation/basics/Linux.html b/html/documentation/basics/Linux.html
    index af0b9b6d2..07207d803 100644
    --- a/html/documentation/basics/Linux.html
    +++ b/html/documentation/basics/Linux.html
    @@ -16,9 +16,11 @@
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
     	<title>Linux Basics</title>
     </head>
     <body>
    +
     <div w3-include-html="/documentation/pageHeader.html" id="pageHeader"></div>
     <div class="Layout">
     <div class="Layout-sidebar" w3-include-html="/documentation/sidebar.html" id="sidebar"></div>
    @@ -27,21 +29,51 @@
     <h1>Overview</h1>
     <p>
     This page is for people will little or no Linux experience.
    -It won't make you a guru,
    -but should give you enough information to install and use the Allsky software.
    -If you are new to the Raspberry Pi you may want to view the
    -<a allsky="true" href="Pi.html">Pi Basics</a>.
    -</p>
    -<p>
    -To keep the other Allsky documentation pages a manageable length,
    -they assume you have basic Linux and Pi knowledge.
    +It will give you enough information to install and use Allsky.
    +If you are new to the Raspberry Pi see the
    +<a allsky="true" external="true" href="Pi.html">Pi Basics</a> page.
    +The Allsky documentation assumes you know what's on these two pages.
     For example, you know how to log into the Pi, move around to various directories,
     view and edit files, and execute commands.
     </p>
     <p>
     To get information on any Linux command, enter <code>man <i>command_name</i></code>,
    -for example, <code>man date</code>.
    +for example, <code>man date</code> at a command prompt on your Pi.
    +</p>
    +
    +<h2>Logging into your Pi</h2>
    +<details><summary></summary>
    +<p>
    +There are several ways you can log into a Pi.
    +<ol>
    +	<li>
    +	Plug a keyboard, mouse, and monitor directly into the Pi.
    +	Although this is the easiest way, it requires that you have access to the Pi,
    +	which when used with an allsky camera is rarely the case,
    +	except when you're initially setting up the Pi.
    +	<li>
    +	The Pi comes with a <b>VNC</b> (Virtual Network Connection) server,
    +	so you can access the Pi remotely by installing a VNC client on your
    +	PC, phone, tablet, or almost any other device.
    +	When using the VNC client, you see your Pi desktop and interact with it as if you
    +	were directly attached to the Pi.
    +	<li>
    +	<b>SSH</b> (<u>s</u>ecure <u>sh</u>ell) is used to log into the Pi from a remote devices,
    +	usually a PC or Mac but could be a tablet or phone.
    +	SSH needs to be enabled on the Pi - this should be done while
    +	<a href="../explanations/imageSDcard.html" external="true">imaging your SD card</a>.
    +	<br>Once SSH is enabled, do the following to use it:
    +	<ol class="minimalPadding">
    +		<li>On the remote device, open a terminal window
    +			(<code>cmd</code> on Windows or <code>terminal</code> on Mac)
    +		<li>Type <code>ssh pi@allsky.local</code>.
    +		<li>Enter the password when prompted.
    +		<li>You can now enter Linux commands.
    +	</ol>
    +</ol>
     </p>
    +</details>
    +
     
     <h2>Linux Desktop</h2>
     <details><summary></summary>
    @@ -49,36 +81,32 @@ <h2>Linux Desktop</h2>
     Linux is similar in many ways to Windows and MacOS - they
     all have a Desktop as well as a command-line interface (also called a "terminal window").
     Most of the interactions you'll have with your Pi for Allsky-related tasks
    -will be via the command-line (or via the WebUI),
    +will be via the command-line,
     whereas in Windows and MacOS the terminal window is rarely used.
     </p>
     <p>
     The screenshot below shows a typical Linux desktop.
     Starting from the far left are 5 icons followed by a file explore window and
     a terminal window showing the output of the <code>date</code> command.
    -At the bottom is the taskbar - very similar to Windows and MacOS.
    +At the bottom is the taskbar.
     </p>
     <a href="Desktop.png" target="_blank">
     <img allsky="true" title="Linux Desktop - Click for full version" alt="Desktop" src="Desktop.png" loading="lazy">
     </a>
    -</details>
     
    -<h2>Terminal Window</h2>
    -<details><summary></summary>
     <p>
    -A Terminal Window emulates the old ASCII-based character-only terminals.
    -Notice the one above has no graphics - only characters.
    -It prompts for commands to enter, so is also called a "command-line interface".
    -Whereas Windows has one primary command-line interpreter
    -(called <code>cmd</code>, which looks like a DOS window),
    -Linus has multiple interpreters, called "shells".
    -When UNIX was first invented in the late 1960s, the only shell available was <code>sh</code>.
    -Since then, many other shells have been created; the one Allsky uses is <code>bash</code>
    -since it has many useful features not in <code>sh</code>.
    -You'll notice the commands in the <span class="fileName">allsky/scripts</span> directory have an
    -extension of <code>.sh</code> which signifies they are shell scripts.
    +Terminal Windows like the one above with a gray background have no graphics - only characters.
    +They prompt for commands to enter, so are also called a "command-line interface".
    +Windows' primary command-line interpreter is called <code>cmd</code>
    +and looks like a DOS window.
    +The command-line interpreter, also called a "shell",
    +used by Allsky is called <code>bash</code>.
    +</p>
    +<p>
    +Notice the <span class="fileName">install.sh</span> file in the left window
    +above has an extension of <code>.sh</code> which signifies it's a shell script.
     Unlike in Windows, Linux commands don't actually need an extension,
    -but all the Allsky text-based commands have extensions so it's obvious what type of file it is.
    +but the Allsky text-based commands have extensions so it's obvious what type of file they are.
     </p>
     <p>
     Throughout the Allsky documentation you'll see instructions like:
    @@ -93,62 +121,25 @@ <h2>Terminal Window</h2>
     <p>
     The <code>cd</code> and <code>ls -l</code> commands should be executed at the command-line.
     </p>
    -<blockquote>
    -Because Allsky commands are executed on the command-line,
    -this page only mentions the commands to execute to perform an action,
    -and ignores any associated desktop icon that performs the same action.
    -</blockquote>
    -</details>
    -
    -<h2>Logging in</h2>
    -<details><summary></summary>
    -<p>
    -There are several ways you can log into a Pi.
    -<ol>
    -<li>
    -<b>On the console</b>, with a keyboard, mouse, and monitor plugged directly into the Pi.
    -Although this is the easiest way, it requires that you have access to the Pi,
    -which when used with an allsky camera is rarely the case,
    -except when you're initially setting up the Pi.
    -<li>
    -The Pi comes with a <b>VNC</b> (Virtual Network Connection) server,
    -so you can access the Pi remotely by installing a VNC client on your
    -PC, phone, tablet, or almost any other device.
    -When using the VNC client, you see your Pi desktop and interact with it as if you
    -were directly attached to the Pi.
    -<li>
    -<b>SSH</b> (<u>s</u>ecure <u>sh</u>ell) is used to log into the Pi from a remote devices,
    -usually a PC or Mac but could be a tablet or phone.
    -SSH needs to be enabled on the Pi - the easiest way is when using the
    -"Raspberry Pi Imager" to create the intial Raspberry Pi OS.
    -Once SSH is enabled, do the following to use it:
    -<ol>
    -	<li>On the remote device, open a terminal window
    -		(<code>cmd</code> on Windows or <code>terminal</code> on Mac)
    -	<li>Type <code>ssh pi@allsky.local</code>.
    -	<li>Enter the password when prompted.
    -	<li>You can now enter Linux commands.
    -</ol>
    -<br><span style="color: red">NEED MORE INFO</span>
    -</ol>
    -</p>
     </details>
     
     
     <h2>Executing commands</h2>
     <details><summary></summary>
     <p>
    -To execute a command you simply type it's name followed by any optional arguments:
    +To execute a command type its name followed by any optional arguments:
     <pre>
     date
     sudo systemctl start allsky
     </pre>
     In this example, <code>date</code> and <code>sudo</code> are the names of commands.
     <code>date</code> has no arguments and <code>sudo</code> has 3 arguments.
    -<h3>Permissions</h3>
    +</p>
    +<p>
     Just like in other operating systems, files and commands have permissions that determines who
     can view, edit, and execute them.
    -By default, when you login to the Pi, you are running as the "pi" login,
    +By default, when you log into to the Pi, you are running as the "pi" login
    +(or whatever you called it),
     which has limited permissions, e.g., it generally can't update system files.
     In Linux, the "root" login ("admin" in Windows) can do anything.
     <br>
    @@ -159,30 +150,30 @@ <h3>Permissions</h3>
     </p>
     <blockquote>
     An interesting fact - the "root" login is also called "<u>s</u>uper <u>u</u>ser",
    -hence <code><u>su</u>do</code> - "super user do".
    +hence <code><u>su</u>do</code> - "<u>s</u>uper <u>u</u>ser <u>do</u>".
     </blockquote>
     If an argument contains a space or other "special characters", it must be quoted,
     otherwise it's treated as multiple arguments:
     <pre>
     echo "Hello world!"
     echo         "Hello world!"
    +echo "Hello    world!"
    +echo Hello    world!
     </pre>
    -
     <p>
    -Note the first line above has a single space between <code>echo</code> and the argument
    +The first line above has a single space between <code>echo</code> and the argument
     and the second line has several spaces.
     Those spaces are considered "white space", and unless quoted,
     the shell doesn't care if there's one white space or a million.
    -Hence, both the lines above are treated the same.
    -<br>
    -What happens if you don't included that white space?
    -The shell will look for a command called <code>echoHello world!</code>
    -which of course doesn't exist and you'll get an error message.
    -<br>
    -What's the difference in these?
    +Hence, both those lines are treated the same.
    +The output from all four lines is below.
    +The first three commands have one argument (it's surrounded by double quotes)
    +and the forth command has two arguments since there are no quotes.
     <pre>
    -echo "Hello world!"
    -echo "Hello    world!"
    +Hello world!
    +Hello world!
    +Hello    world!
    +Hello world!
     </pre>
     </p>
     </details>
    @@ -195,30 +186,32 @@ <h2>Moving around the filesystem</h2>
     e.g., <span class="fileName">C:\</span>.
     Linux does not use drive letters but has a single "root" directory,
     called <span class="fileName">/</span>.
    -Note that the characters <span class="fileName">\</span> and
    -<span class="fileName">/</span> also separate directories,
    -for example:
    +Windows uses <span class="fileName">\</span> to separate directories and
    +Linux uses <span class="fileName">/</span>, for example:
     <span class="fileName">/home/pi/allsky</span>
     <br>
     The above string is called a "path name" since it names the path to a file or directory,
     in this case, the "allsky" directory.
    +</p>
    +<p>Using <span class="fileName">/home/pi/allsky</span> as an example:
     <ul>
    -<li>The first <span class="fileName">/</span> is the root directory.
    -<li><span class="fileName">home</span> is a directory under the root directory, and contains home directories for
    -	all the users on your Pi (on most machines, just the "pi" login).
    -<li>The second and third <span class="fileName">/</span> characters separate directories.
    -<li><span class="fileName">pi</span> is a sub-directory in the
    -<span class="fileName">home</span> directory that contains all the "pi"
    -	login's files and directories and is called pi's HOME directory.
    -<li><span class="fileName">allsky</span> is the name of a directory that contains
    -most of the Allsky files, images, etc.
    +	<li>The first <span class="fileName">/</span> is the root directory.
    +	<li><span class="fileName">home</span>
    +		is a directory under the root directory, and contains home directories for
    +		all the users on your Pi (on most machines, just the "pi" login).
    +	<li>The second and third <span class="fileName">/</span> characters separate directories.
    +	<li><span class="fileName">pi</span> is a sub-directory in the
    +	<span class="fileName">home</span> directory that contains all the "pi"
    +		login's files and directories and is called pi's HOME directory.
    +	<li><span class="fileName">allsky</span> is the name of a directory that contains
    +		the Allsky files, commands, images, etc.
     </ul>
     <p>
     To move from one location to another, use the <code>cd</code>
     (<u>c</u>hange <u>d</u>irectory) command.
    -In the examples below, it's assumed you're logged in as "pi",
    +In the examples below, it's assumed you're logged in as "pi".
     <code>~</code> stands for pi's HOME directory,
    -and anything after the "#" is a shell comment.
    +and anything after the <code>#</code> is a comment which is ignored.
     <pre>
     cd           <span class="pl-c"># goes to the HOME directory of whatever login you are</span>
     cd ~/allsky  <span class="pl-c"># goes to pi's "allsky" directory</span>
    @@ -236,58 +229,63 @@ <h2>Listing files and directories</h2>
     To see the contents of a directory, type <code>ls</code> (<u>l</u>ist file<u>s</u>)
     (called <code>dir</code> in Windows).
     Like most commands, <code>ls</code> takes a bunch of arguments.
    -A common one is <code>-l</code> which produces long (i.e., detailed) output.
    +A common one is <code>-l</code> which produces long output.
     </p>
    -<a href="ls.png" target="_blank">
    -<img allsky="true" title="ls output - Click for full version" alt="ls output" src="ls.png" loading="lazy">
    -</a>
    +<img allsky="true" title="ls output" alt="ls output" src="ls.png" loading="lazy">
     <p>
     Note that the output is in color:
    -<ul>
    -<li><span style="color: #23ad1e">Green</span> indicates the file is executable (i.e., a command).
    -<li><span style="color: #595ded">Blue</span> indicates the object is a directory.
    -<li>Black indicates the file is an "ordinary" one.
    +<ul class="minimalPadding">
    +	<li><span style="color: #23ad1e"><strong>Green</strong></span>
    +		indicates the file can be executed (i.e., is a command).
    +	<li><span style="color: #595ded"><strong>Blue</strong></span>
    +		indicates the object is a directory.
    +	<li><strong>Black</strong> indicates the file is an "ordinary" one.
     </ul>
     </p>
     <p>
    -The detailed view includes the following seven columns of data:
    +The long view includes the following 7 columns of data:
     <ol>
     <li><b>Permissions</b>.
     	All Linux files have an owner as well as a group the file is in.
     	There are three sets of permissions:
    -	<ol>
    -	<li>The <b>owner</b> of the file.
    -	<li>The <b>group</b> of the file.
    -	<li>Everyone else, called <b>other</b>.
    +	<ol class="minimalPadding" type="A">
    +		<li>The <b>owner</b> of the file.</li>
    +		<li>The <b>group</b> of the file.</li>
    +		<li>Everyone else, called <b>other</b>.</li>
     	</ol>
     	<p>
     	A typical listing of the permissions is <code>-rwxr-xr-x</code>.
     	If an object is a directory, the first character is <code>d</code>,
     	otherwise it's <code>-</code>.
     	<br>
    -	Each of the three sets of permissions above have a read, write, and execute permission flag,
    +	The <b>owner</b>, <b>group</b>, and <b>other</b> entities
    +	have three permission flags each (read, write, and execute)
     	for a total of 9 more characters, in groups of 3:
     	</p>
    -	<ol>
    -	<li>The first character of each group determines if the
    -		owner/group/other can <b>read</b> the file.
    -		<code>r</code> means the login can read the file; <code>-</code> means it can't.
    -		<br>For directories, read permission means the login can view the contents of a directory.
    -	<li>The second character determines if the owner/group/other
    -		can <b>write</b> the file.
    -		<code>w</code> means the login can write the file; <code>-</code> means it can't.
    -		<br>For directories, write permission means the login can add to,
    -		or delete from, the directory, e.g., create a file in the directory.
    -	<li>The third character determines if the owner/group/other
    -		can <b>execute</b> the file.
    -		<code>x</code> means the login can write the file; <code>-</code> means it can't.
    -		Commands must have this flag set, otherwise they can't be executed.
    -		<br>For directories, execute permissions mean the login can <code>cd</code> into it.
    +	<ol type="A">
    +		<li>The first character of each group determines if the
    +			entity can <b>read</b> the file.
    +			<code>r</code> means yes; <code>-</code> means no.
    +			<br>For directories, read permission means the entity
    +			can view the contents of a directory.</li>
    +		<li>The second character determines if the entity
    +			can <b>write</b> to the file.
    +			<code>w</code> means yes; <code>-</code> means no.
    +			<br>For directories, write permission means the entity can add to,
    +			or delete from, the directory, e.g., create a file in the directory.</li>
    +		<li>The third character determines if the entity
    +			can <b>execute</b> the file.
    +			<code>x</code> means yes; <code>-</code> means no.
    +			Commands must have this flag set, otherwise they can't be executed.
    +			<br>For directories, execute permissions mean the entity can
    +			<code>cd</code> into it; "executing" a directory doesn't make sense.</li>
     	</ol>
    +	<p>
     	In our example (<code>-rwxr-xr-x</code>), the object is a file,
     	the owner can read, write, and execute it (which implies it's a command),
    -	anyone in the group can read and execute the command but not write, i.e., change it,
    -	and everyone else can also read and execute it.
    +	anyone in the group the file is in can read and execute the command but not write,
    +	i.e., change it, and everyone else can also read and execute it.
    +	</p>
     <li>Number of <b>links</b> to the file.
     	You can normally ignore this field.
     <li>Name of the <b>owner</b> of the file.
    @@ -299,26 +297,25 @@ <h2>Listing files and directories</h2>
     </ol>
     </p>
     <p>
    -In the example above that all but one object is owned by the "pi" login
    -and is in the "pi" group.
    -This is what you'd expect since the "pi" login was used to install Allsky.
    -Note, however, this entry:
    +In the screenshot above all the objects are owned by the <code>pi</code> login,
    +and all but one object is also in the <code>pi</code> group.
    +Note this entry:
     <a href="ls-config.png" target="_blank">
     <img allsky="true" title="ls output" alt="ls output for config directory" src="ls-config.png" loading="lazy">
     </a>
     <br>
    -It's in the "www-data" group, which is the group that the web server runs in.
    -Because the web server (which is what runs the WebUI) creates configuration files in
    -that directory, it also has "rwx" group permissions.
    +It's in the <code>www-data</code> group, which is the group that the web server runs in.
    +Because the web server (which runs the WebUI) creates configuration files in
    +that directory, it also has <code>rwx</code> group permissions so it can look
    +in the directory and create files there.
     </p>
     </details>
     
     <h2>Viewing / editing files</h2>
     <details><summary></summary>
     <p>
    -The <code>cat</code> (<u>cat</u>enate) command outputs the whole contents of a file
    -as fast as it can.
    -For small files this is fine, but what if the file is larger than the size of your window?
    +The <code>cat</code> (<u>cat</u>enate) command outputs the contents of a file.
    +For small files this is fine, but what if the file is larger than the size of your terminal window?
     In that case, use <code>more</code> which will display the file one page at a time.
     Pressing the space bar will move to the next page.
     If you only want to see the beginning or end of a file,
    @@ -331,15 +328,9 @@ <h2>Viewing / editing files</h2>
     </p>
     <p>
     The most common text-file editor is <code>nano</code>.
    -Before using it you'll probably want to view its manual page.
    +Before using it you'll probably want to view its manual page by typing
    +<code class="nowrap">man nano</code>.
     </p>
    -<blockquote>
    -The Allsky configuration files should ONLY be edited via the WebUI - either
    -via the "Allsky Settings" page for the main "settings" file,
    -or via the WebUI's "Editor" page for the <span class="fileName">config.sh</span> and
    -<span class="fileName">ftp-settings.js</span> files,
    -and the Allsky Website's <span class="fileName">configuration.json</span> file.
    -</blockquote>
     </details>
     
     <h2>Standard in, standard out</h2>
    @@ -347,7 +338,7 @@ <h2>Standard in, standard out</h2>
     <p>
     By default, commands that expect input read it from the keyboard
     (referred to as "standard input" or "stdin")
    -and write to the display ("standard output" or "stdout").
    +and write to the terminal window ("standard output" or "stdout").
     <br>
     If you want the input to come from a file,
     or the output to go to a file, use <code>&lt;</code> or <code>&gt;</code>:
    @@ -375,12 +366,11 @@ <h2>Shutting down the Pi</h2>
     don't like it when you simply unplug the power to the device.
     On the Pi you should instead use the
     <img allsky="true" src="/documentation/img/shutdownPi.png" class="buttonIconLarge" title="Shutdown button" loading="lazy">
    -button on the WebUI's <span class="WebUIPage">System</span> page or via the command prompt:
    +button on the WebUI's <span class="WebUILink">System</span> page or via the command prompt:
     <pre>
     sudo shutdown -r now
     </pre>
    -Unless you're on the console (which is very rare with an allsky camera),
    -you won't know when the Pi has fully shutdown so wait 30 or so seconds
    +You won't know when the Pi has fully shutdown so wait 30 or so seconds
     before turning the power off.
     </details>
     
    @@ -388,10 +378,10 @@ <h2>Shutting down the Pi</h2>
     <h2>Getting help</h2>
     <details><summary></summary>
     <p>
    -As stated previously, you can get information on all Linux commands via the
    +You can get information on all Linux commands via the
     <code>man</code> command.
     Most commands also take a <code>--help</code> option
    -(including the Allsky commands that are desiged to optionally be run manually):
    +(including the Allsky commands that can be run manually):
     </p>
     <p>
     <a href="date--help.png" target="_blank">
    diff --git a/html/documentation/basics/Pi.html b/html/documentation/basics/Pi.html
    index 365532c0c..40fa497fc 100644
    --- a/html/documentation/basics/Pi.html
    +++ b/html/documentation/basics/Pi.html
    @@ -11,12 +11,13 @@
     	<script src="../bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
     	<style>
     		#pageTitle::before {
    -			content: "Pi Basics";
    +			content: "Raspberry Pi Basics";
     		}
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    -	<title>Pi Basics</title>
    +	<script src="../js/all.min.js" type="application/javascript"></script>
    +	<title>Raspberry Pi Basics</title>
     </head>
     <body>
     <div w3-include-html="/documentation/pageHeader.html" id="pageHeader"></div>
    @@ -24,72 +25,52 @@
     <div class="Layout-sidebar" w3-include-html="/documentation/sidebar.html" id="sidebar"></div>
     <div class="Layout-main markdown-body" id="mainContents">
     
    -<h1>Overview</h1>
     <p>
     This page is for people with little or no Pi experience.
    -To keep the other Allsky documentation pages a manageable length,
    -they assume you have basic Pi knowledge.
    +The Allsky documentation assumes you have basic Pi knowledge.
     For example, you know how to turn a Pi on and off and how to connect a camera to it.
    +If not, read this page.
     </p>
     
    -<h3>Operating Systems</h3>
    -<details><summary></summary>
    +<h2>Operating Systems</h2>
     <p>
     The Linux operating system controls the Pi's hardware and lets you run software
     such as Allsky.
    -The two most common versions of Linux for the Pi are <b>Buster</b>,
    -which was released several years ago,
    -and <b>Bullseye</b> which was released late 2021. <b>Bookworm</b> was released with the launch of the Pi 5. 
    -Allsky runs on all three operating systems but use <b>Bookworm</b> 64bit if possible,
    -especially if you have an RPi camera.
    -Allsky works on both the 32-bit and 64-bit versions of Bullseye and Bookworm;
    -there was no official 64-bit version of Buster and Allsky is not supported on it.
    +Just like Windows and MacOS, there are multiple versions of
    +the Pi's operating system (called "Pi OS"),
    +and support for old versions is eventually dropped.
    +</p>
    +<p>
    +<b>Bookworm</b> is the current Pi OS
    +and is what you should run (use the 64-bit version if you can).
    +Allsky runs on the older <b>Bullseye</b> and <b>Buster</b>
    +operating systems but this is the last version of Allsky
    +that will run on <b>Buster</b>.
     </p>
     <p>
     There are different distributions of Linux (called "distros") from various companies.
     While they are basically the same Linux,
     there can be subtle differences and the administrative commands can vary.
     </p>
    -<blockquote>
    -The recommended Linux distro is <b>Debian</b>.
    -We can't guarantee Allsky will run on other distros.
    +<blockquote class="warning">
    +Allsky only supports the <b>Debian</b> distro.
     </blockquote>
    -</details>
     
    -<h3>Raspberry Pi Models</h3>
    -<details><summary></summary>
    +
    +<h2>Raspberry Pi Models</h2>
     <p>
     There are several models of the Pi including Zero, Zero 2, 1, 2, 3, 4, 5, and others.
    -The Pi 3 and 4 are the most common being used for Allsky,
    -but some people have the Pi Zero or Zero 2 because of their low cost.
    -The various models have different processing power;
    +These models have different processing power;
     the Pi 5 is the most powerful, followed by the 4, then 3, then 2, then 1.
     The Zero 2 is near the Pi 3 in terms of processing power but has less memory.
    -The Pi 4 is available with up to 8 GB memory whereas the
    +The Pi 4 and 5 are available with up to 8 GB memory whereas the
     Pi Zero and Zero 2 are limited to 0.5 GB memory.
    -The Pi 5 is the latest version and is supported as of v2023.05.01_04 release.
    -Although Allsky can run on the Zero 2, several GB of swap space are needed,
    -and even then, Allsky just barely runs.
    -The processing power of the Pi Zero is very limited so you likely won't
    -be able to create timelapse videos - stay away from it if possible.
     </p>
     <p>
    -<strong>The recommended unit is a Pi 4 or 5 with 4 GB or more of memory.</strong>
    -Allsky runs nicely on that and the USB 3.0 ports are nice for cameras that support it.
    -</p>
    -</details>
    -
    -<h3>Hardware Overview</h3>
    -<details><summary></summary>
    -<p>
    -Newer devices come with USB 2 ports and the Pi 4 and 5 both have two USB 3 ports.
    -They also have a special port to plug RPi and compatible cameras into.
    +<a href="../miscellaneous/pickingHardware.html" external="true">This page</a>
    +will help you pick a Pi that runs well with Allsky.
     </p>
    -</details>
     
    -<h3 style="color: red">MORE TO COME</h3>
    -If you have any suggestions on what should be added here,
    -please add a <a href="https://github.com/AllskyTeam/allsky/discussions">Discussion</a> item.
     
     </div><!-- Layout-main -->
     </div><!-- Layout -->
    diff --git a/html/documentation/basics/ls-config.png b/html/documentation/basics/ls-config.png
    index 6a1158dd4..e0f0f647e 100644
    Binary files a/html/documentation/basics/ls-config.png and b/html/documentation/basics/ls-config.png differ
    diff --git a/html/documentation/basics/ls.png b/html/documentation/basics/ls.png
    index 5ef2ea57f..2cb1597a4 100644
    Binary files a/html/documentation/basics/ls.png and b/html/documentation/basics/ls.png differ
    diff --git a/html/documentation/changeLog.html b/html/documentation/changeLog.html
    index ce2a440c1..27b3346aa 100644
    --- a/html/documentation/changeLog.html
    +++ b/html/documentation/changeLog.html
    @@ -22,37 +22,337 @@
     </head>
     
     <body>
    -	<div w3-include-html="/documentation/pageHeader.html" id="pageHeader"></div>
    -	<div class="Layout">
    -		<div class="Layout-sidebar" w3-include-html="/documentation/sidebar.html" id="sidebar"></div>
    -		<div class="Layout-main markdown-body" id="mainContents">
    -			<p>
    -				This page lists the changes to all Allsky releases.
    -				You can also see the
    -				<a allsky="true" external="true" href="knownIssues.html">Known Issues and Limitations</a>
    -				of the current release.
    -			</p>
    -
    -			<blockquote>
    -				Wonder what all the colors below mean?
    -				Check out
    -				<a allsky="true" external="true" href="settings/EditorColors.html">this page</a>.
    -			</blockquote>
    -			
    -			<h2>v2023.05.01_05 - Point Release # 5</h2>
    -			<details>
    -				<summary></summary>
    -				<h4>Bug Fixes</h4>
    -				<ul class="minimalPadding">
    -					<li>Fixed security bug that allowed hackers to overwrite Allsky files.</li>
    -				</ul>
    +<div w3-include-html="/documentation/pageHeader.html" id="pageHeader"></div>
    +<div class="Layout">
    +	<div class="Layout-sidebar" w3-include-html="/documentation/sidebar.html" id="sidebar"></div>
    +	<div class="Layout-main markdown-body" id="mainContents">
    +		<p>
    +			This page lists the changes to all Allsky releases.
    +			You can also see the
    +			<a allsky="true" external="true"
    +				href="knownIssues.html">Known Issues and Limitations</a>
    +			of the current release.
    +		</p>
    +
    +		<blockquote>
    +			Wonder what all the colors below mean?
    +			Check out
    +			<a allsky="true" external="true" href="settings/EditorColors.html">this page</a>.
    +		</blockquote>
    +
    +
    +		<!-- =========================================== -->
    +
    +		<h2>v2024.12.06</h2>
    +		<details open> <summary></summary>
    +
    +			<h4>Changes that may require action (upgrades only)</h4>
    +			<ul>
    +				<li><blockquote class="warning">
    +					The changes in this section may require settings changes;
    +					after upgrading, check that your settings and images look right.
    +					</blockquote>
    +				</li>
    +				<li>The <span class="fileName">config.sh</span>
    +					and <span class="fileName">ftp-settings.sh</span>
    +					files are gone - their settings are now in the WebUI.
    +					<br><span class="possibleAction">Possible action: </span>
    +					The upgrade should migrate your prior settings to the WebUI
    +					unless you have a very old version of Allsky in which case
    +					you'll be notified during the upgrade on what to do.
    +				</li>
    +				<li>The <b>CROP</b> settings have been simplified to specifiy the number
    +					of pixels to crop from the top, right, bottom, and left of an image.
    +					<br><span class="possibleAction">Possible action: </span>
    +					If you cropped your images the upgrade will notify you
    +					to re-enter the settings using the new fields.
    +				</li>
    +				<li>The <span class="WebUISetting">Remove Bad Images Thresholds</span>
    +					settings are now between 0.0 and 1.0 instead of 0 - 100.
    +					<br><span class="possibleAction">Possible action: </span>
    +					The upgrade should adjust your threshold values to the
    +					new range, but check.
    +				</li>
    +				<li>Allsky now uses the mean brightness of an image
    +					to determine if it is too bright or too dark.
    +					This is the same way auto-exposure determines the exposure to use.
    +					Prior releases looked at the whole image including any dark borders.
    +					<br><span class="possibleAction">Possible action: </span>
    +					You may need to adjust your
    +					<span class="WebUISetting">Remove Bad Images Thresholds</span>
    +					to take into account for this change.
    +				</li>
    +				<li>(ZWO only) The mean brightness of an images is
    +					now a number between 0.0 and 1.0; it used to be 0 - 255.
    +					This matches the <span class="WebUISetting">Mean Target</span>
    +					settings which have always been between 0.0 and 1.0 as well as the
    +					<span class="WebUISetting">Remove Bad Images Thresholds</span>.
    +					settings, making it easier to determine what values to use.
    +					<br><span class="possibleAction">Possible action: </span>
    +					If the <b>MEAN</b> variable is on your overlay you may need
    +					to change the format it's displayed in.
    +					A two or three-digit number is best.
    +				</li>
    +				<li>The WebUI now hides the settings in each section unless there's
    +					an error in that section.
    +					To view a section's settings, click the
    +					<i class='fa fa-chevron-down fa-fw'></i> symbol on the left side
    +					of a section heading.
    +					<br><span class="possibleAction">Possible action: </span>
    +					This may take a little time to get used to.
    +					However, with the significant increase in WebUI settings
    +					it would be difficult to find what you want if
    +					the sections weren't hidden.
    +				</li>
    +				<li><code>check_allsky.sh</code> is now called
    +					<code>checkAllsky.sh</code>
    +					to be consistant with other program names.
    +					<br><span class="possibleAction">Possible action: </span> None.
    +			</ul>
    +
    +			<h4>Enhancements / Changes</h4>
    +			<ul>
    +				<li>Setting up an Allsky Website on the Pi is now trivial - just enable the
    +					the <span class="WebUISetting">Use Local Website</span> setting
    +					and optionally set the number of days' images to keep via the
    +					<span class="WebUISetting">Days To Keep on Pi Website</span> setting.
    +					No more trying to figure out how to set the variables!
    +				</li>
    +				<li>Setting up a Website on a remote server is easier since the only
    +					directory you have to specify is the top-level
    +					<span class="WebUISetting">Image Directory</span> in the WebUI.
    +				</li>
    +				<li>Installing a Website on a remote server is significantly easier as well.
    +					Use the new <code>remoteWebsiteInstall.sh</code> command;
    +					it will upload the Allsky Website files for you and remove any
    +					old, unneeded files.
    +					If this is a new remote Website,
    +					<code>remoteWebsiteInstall.sh</code> will ask you whether or not to
    +					also upload any startrails, keograms, and timelapse videos on your Pi.
    +				</li>
    +				<li>Images, timelapse, keograms, and startrails can now be written
    +					to a <u>remote server</u> in addition to, or instead of, a remote Website.
    +					This is useful if you want to include those images in a different website
    +					or want to archive them.
    +				</li>
    +				<li>The new <code>allsky-config</code> command
    +					is similar to the <code>raspi-config</code> command
    +					but for Allsky information and configuration.
    +					This command is mentioned in several documentation pages
    +					(as well as below) to easily describe how to do something.
    +					<br>
    +					Execute <code>allsky-config</code> to see what it can do.
    +				</li>
    +				<li>Running <code>allsky-config   samba</code>
    +					allows you to mount the <span class="fileName">~/allsky</span>
    +					directory on a PC or MAC.
    +					See <a allsky="true" external="true"
    +						href="/documentation/explanations/SAMBA.html">Copy files to / from a Pi</a>
    +					for more information.
    +				</li>
    +				<li>The new <code>testUpload.sh</code> command
    +					tries to upload a test file to either a remote Website or remote server.
    +					If the upload fails, the command
    +					<strong>attempts to tell you why it failed and what to do about it</strong>.
    +				</li>
    +				<li>When changing settings, Allsky only restarts if it needs to.
    +					Some settings, like the <span class="WebUISetting">Owner</span>
    +					don't impact taking images so there's no reason to restart Allsky.
    +					A few settings cause Allsky to stop after being changed.
    +					Hovering you mouse over a WebUI data field displays a popup
    +					that indicates what will happen after changing that field.
    +				</li>
    +				<li>The <span class="WebUIValue">module</span>
    +					<span class="WebUISetting">Overlay Method</span>
    +					is now the default for new installations.
    +					In the next major Allsky release the <span class="WebUIValue">legacy</span>
    +					method will be removed and the only way to add text will be using the
    +					<span class="editorName">Overlay Editor</span>.
    +				</li>
    +				<li>You can now have more than one ZWO camera connected,
    +					and on the Pi 5, more than one RPi camera connected.
    +					The installation will show ALL attached cameras.
    +				</li>
    +				<li>If you change cameras without notifying Allsky
    +					(e.g., you shutdown the Pi, replace the camera with a different model,
    +					then restart the Pi), Allsky will not start and will put a
    +					message in the WebUI telling you how to properly change cameras.
    +					No message will be given if you replace a camera with the same model,
    +					e.g., an RPi HQ with another RPi HQ camera.
    +				</li>
    +				<li>If you try using an <u>unsupported</u> camera you'll
    +					receive a message stating that and telling you what to do
    +					and how to request support for the camera.
    +				</li>
    +				<li>All ZWO cameras as of September 7, 2024 are supported (library version 1.36).
    +				</li>
    +				<li>Support for some new third-party RPi cameras was added.
    +					<blockquote>
    +					Run
    +					<code>allsky-config  show_supported_cameras</code>
    +					to see the supported ZWO and RPi cameras.
    +					<p>
    +					The ZWO list is over 130 entries so if you want to check
    +					if a camera you're considering getting is supported, run:
    +					</p>
    +					<pre>allsky-config  show_supported_cameras --zwo | grep -i "CAMERA_NAME"</pre>
    +					replacing <code>CAMERA_NAME</code> with the name with <strong>OUT</strong>
    +					<code>ASI</code>, e.g., <code>178mc</code>.
    +					<p>Checking for an RPi camera is easier:
    +					<pre>allsky-config  show_supported_cameras --rpi</pre>
    +					</blockquote>
    +				</li>
    +				<li>The <span class="WebUILink"><strong>LAN</strong> Dashboard</span>
    +						and <span class="WebUILink"><strong>WLAN</strong> Dashboard</span>
    +						WebUI pages now display <strong>all</strong>
    +						network adapters, regardless of their names.
    +				</li>
    +				<li>The <span class="fileName">endOfNight_additionalSteps.sh</span>
    +					file is no longer supported.
    +					The few people that had that file should use the "night to day"
    +					module flow instead.
    +				</li>
    +				<li>When taking dark frames, any images that is too bright is deleted.
    +					This can happen if you start taking darks before covering the lens
    +					or if the lens cover lets in some light, e.g., a plastic lens cap.
    +				</li>
    +				<li>DHCP on the Pi can be configured via the WebUI's
    +					<span class="WebUILink">Configure DHCP</span> page.
    +					This is an advanced option that is not used very often.
    +				</li>
    +				<li>Overlay changes:
    +					<ul>
    +						<li>The overlay module debugging output is easier to read.</li>
    +						<li>You can now create different overlays for daytime and nighttime.
    +							The new <span class="managerName">Overlay Manager</span>
    +							is available from the main toolbar in the
    +							<span class="editorName">Overlay Editor</span>.
    +							Several overlay templates for different cameras are provided
    +							which can be customised to your requirements.
    +							If you have customised the overlay then during the upgrade
    +							process a new custom overlay will be created for you and assigned
    +							to both daytime and nighttime captures.
    +							If you have not customised the overlay then Allsky will
    +							attempt to use the best overlay for your camera.</li>
    +						<li>The <span class="editorName">Overlay Editor</span>
    +							will display a warning if any of the fields on the overlay
    +							are outside the image area.
    +							The fields can be fixed which will move them into the bounds of the image.
    +							See <a href="https://github.com/AllskyTeam/allsky/issues/3317"
    +								allsky="true" external="true">[BUG] If a field is outside the image ...</a></li>
    +						<li>If images are not being captured the
    +					    	<span class="editorName">Overlay Editor</span> will not start and
    +		    				will display a message and wait until images are being captured.
    +		    				This prevents a (usually much smaller) notification image
    +							from being used as the <span class="editorName">Overlay Editor</span>
    +							background.</li>
    +					</ul>
    +				<li>See <a href="/documentation/modules/modules.html" allsky="true" external="true">Modules Documentation</a>
    +					for details on Extra Module changes.
    +				<li>New Modules:
    +					<ul class="minimalPadding">
    +						<li><strong>Allsky AI</strong> - Detects cloud cover using AI</li>
    +						<li><strong>Allsky Fans</strong> - Controls a fan based upon cpu temperature</li>
    +						<li><strong>Allsky Border</strong> - Expands an image to include additional borders </li>
    +						<li><strong>Allsky HDD Temp</strong> - Retrives hard drive temperatures using SMART</li>
    +						<li><strong>Allsky ina3221</strong> - Allows current and voltage measurements</li>
    +						<li><strong>Allsky influxdb</strong> - Allows data to be written to uinfluxdb</li>
    +						<li><strong>Allsky light</strong> - Determines light levels using a TSL2591</li>
    +						<li><strong>Allsky LightGraph</strong> - Displays a graph of day and night</li>
    +						<li><strong>Allsky ltr390</strong> - Measures UV levels</li>
    +						<li><strong>Allsky mlx90640</strong> - Captures an IR image of the sky (very experimental)</li>
    +						<li><strong>Allsky Publish Data</strong> - Publish Allsky data to Redis, MQTT, or REST</li>
    +						<li><strong>Allsky Temp</strong> - Reads up to three temperature sensors
    +							and controls a gpio pin based upon the temperature</li>
    +					</ul>
    +				</li>
    +			</ul>
    +
    +			<h4>New Settings</h4>
    +			<ul class="mediumPadding">
    +				<li><span class="WebUISetting">Images Sort Order</span>
    +					determines the sort order of the images on the
    +					WebUI's <span class="WebUILink">Images</span> page.
    +				</li>
    +				<li><span class="WebUISetting">Show Updated Message</span>
    +					allows hiding the
    +					<span class="alert-info">Daytime images updated every...</span>
    +					message on the WebUI's <span class="WebUILink">Live View</span> page.
    +				</li>
    +				<li><span class="WebUISetting">Nighttime Capture</span> and
    +					<span class="WebUISetting">Nighttime Save</span>
    +					are the same as the daytime settings but for nighttime.
    +				</li>
    +				<li><span class="WebUISetting">ZWO Exposure Type</span>
    +					specifies how images should be taken.
    +					See the documentation for a description of the different types.
    +				</li>
    +			</ul>
    +
    +			<h4>Deleted Settings</h4>
    +			<ul class="mediumPadding">
    +				<li>The Image <span class="WebUISetting">Width</span> and
    +					<span class="WebUISetting">Height</span>
    +					settings were deleted since they are no longer needed.
    +					Use the crop and/or resize settings instead.
    +				</li>
    +				<li>The <span class="WebUISetting">Version 0.8 Exposure</span>
    +					setting was deleted since it's functionality is incorporated
    +					into the <span class="WebUISetting">ZWO Exposure Type</span> setting.
    +				</li>
    +				<li>The <span class="WebUISetting">New Exposure Algorithm</span>
    +					setting was deleted because it produced better results
    +					so is now always used.
    +				</li>
    +				<li>The <span class="shSetting">REMOVE_BAD_IMAGES</span> setting was
    +					deleted to keep people from disabling the check.
    +					In addition to "too dark" and "too light" images, images that are empty
    +					and corrupt are also checked for, and those images kept timelapses
    +					from being created.
    +					If you don't want to run the brightness checks set their
    +					<span class="WebUISetting">Remove Bad Images Thresholds</span> to
    +					<span class="WebUIValue">0</span>.
    +				</li>
    +				<li>The <span class="WebUISetting">Brightness</span> settings
    +					were deleted since there is no need for them.
    +					Changes to brightness should be done via the
    +					<span class="WebUISetting">Target Mean</span> settings.
    +				</li>
    +				<li>(ZWO only) The <span class="WebUISetting">Offset</span> setting
    +					was deleted.  It brightened every pixel by the amount specified
    +					and is not needed with allsky cameras.
    +				</li>
    +			</ul>
    +
    +			<h4>Bug Fixes</h4>
    +			<ul>
    +				<li><code>ASI_ERROR_TIMEOUT</code> messages are mostly gone.
    +					<b>Yea!!!</b>
    +				</li>
    +				<li>The ROI selection code assumed the captured image was always a jpg. It now 
    +					looks in the settings to determine the correct image name. See 
    +					<a href="https://github.com/AllskyTeam/allsky/pull/3337" target="_blank" allsky="true" external="true">Ensure the ROI editor uses the correct image</a>
    +				</li>
    +				<li>Date formats in the overlay editor are now working correctly</li>			
    +			</ul>
    +
    +			<h4>Maintenance</h4>
    +			<ul>
    +				<li>All setting names are now lowercase and boolean settings are
    +					"true" and "false" rather than 1 and 0.
    +					These changes should have no impact on users.
    +				</li>
    +			</ul>
    +
    +			<hr class="separator"><!-- =========================================== -->
    +		</details>
     
    -				<hr class="separator"><!-- =========================================== -->
    -			</details>
     
    -			<h2>v2023.05.01_04 - Point Release # 4</h2>
    -			<details>
    -				<summary></summary>
    +		<h2>v2023.05.01</h2>
    +			<details> <summary></summary>
    +
    +			<h3>v2023.05.01_04 - Point Release # 4</h3>
    +			<details sub> <summary></summary>
     				<h4>Enhancements</h4>
     				<ul>
     					<li>Added support for Bookworm, the Pi Operating System released
    @@ -96,7 +396,7 @@ <h4>Bug Fixes</h4>
     						<span class="shSetting">IMAGE_DIR</span> to
     						<code>${ALLSKY_WEBSITE}</code>
     						when only a local Website exists.
    -					<li>The WebUI's <span class="WebUIWebPage">Images</span> page
    +					<li>The WebUI's <span class="WebUILink">Images</span> page
     						now only shows icons when there is at least one file
     						of the specified type.
     						For example, if there is a keogram for the current date the
    @@ -110,9 +410,8 @@ <h4>Bug Fixes</h4>
     			</details>
     
     
    -			<h2>v2023.05.01_03 - Point Release # 3</h2>
    -			<details>
    -				<summary></summary>
    +			<h3>v2023.05.01_03 - Point Release # 3</h3>
    +			<details sub> <summary></summary>
     				<h4>Enhancements</h4>
     				<ul>
     					<li>Added support for RPi Version 1 (ov5647) camera.</li>
    @@ -151,9 +450,8 @@ <h4>Bug Fixes</h4>
     			</details>
     
     
    -			<h2>v2023.05.01_02 - Point Release # 2</h2>
    -			<details>
    -				<summary></summary>
    +			<h3>v2023.05.01_02 - Point Release # 2</h3>
    +			<details sub> <summary></summary>
     				<h4>Enhancements</h4>
     				<ul>
     					<li>The <span class="WebUISetting">Mean Threshold</span> setting now has
    @@ -171,8 +469,8 @@ <h4>Enhancements</h4>
     						<ul>
     							<li>Add <span class="WebUIValue">--metadata &nbsp; /home/pi/allsky/config/overlay/extra/libcamera.json</span>
     								to the <span class="WebUISetting">Extra Arguments</span> setting in the WebUI.</li>
    -							<li>In the <span class="WebUIWebPage">Overlay Editor's</span>
    -								<span class="ManagerName">Variable Manager</span>,
    +							<li>In the <span class="WebUILink">Overlay Editor's</span>
    +								<span class="managerName">Variable Manager</span>,
     								look for <span class="variableManagerVariableName">AS_SensorTemperature</span> in the
     								<span class="variableManagerTab">All Variables</span> tab.
     								Click on <span class="btn btn-primary btn-small">+</span>
    @@ -206,9 +504,8 @@ <h4>Bug Fixes</h4>
     				<hr class="separator"><!-- =========================================== -->
     			</details>
     
    -			<h2>v2023.05.01_01 - Point Release # 1</h2>
    -			<details>
    -				<summary></summary>
    +			<h3>v2023.05.01_01 - Point Release # 1</h3>
    +			<details sub> <summary></summary>
     				<h4>Enhancements</h4>
     				<ul>
     					<li>If multiple consecutive "bad" images are found,
    @@ -221,7 +518,7 @@ <h4>Enhancements</h4>
     						it may take many images before it gets a good one to save.
     						The new warning image makes it obvious Allsky hasn't hung.
     					</li>
    -					<li>In the WebUI's <span class="WebUIWebPage">Editor</span> page,
    +					<li>In the WebUI's <span class="WebUILink">Editor</span> page,
     						the buttons (e.g., "Save changes") are now at the top of the page
     						and stay there as you scroll down.
     						A new "Top" button appears at the bottom of the page after you scroll
    @@ -327,9 +624,8 @@ <h4>Bug Fixes</h4>
     				<hr class="separator"><!-- =========================================== -->
     			</details>
     
    -			<h2>v2023.05.01 - Current Major Release</h2>
    -			<details>
    -				<summary></summary>
    +			<h3>v2023.05.01 - Base Release</h3>
    +			<details> <summary></summary>
     
     				<h4>Core Allsky</h4>
     				<ul>
    @@ -534,7 +830,8 @@ <h4>WebUI</h4>
     
     				<h4>Allsky Website</h4>
     				<ul>
    -					<li> The Allsky Website is now installed in <span class="fileName">~/allsky/html/allsky</span>.
    +					<li> The Allsky Website is now installed in
    +						<span class="fileName">~/allsky/html/allsky</span>.
     					<li> If an older version of the Website is found during Website installation you'll be prompted
     						to have its images and settings moved to the new location.
     					<li> The home page can be customized:
    @@ -604,110 +901,112 @@ <h4>Allsky Website</h4>
     
     				<hr class="separator">
     			</details>
    +		</details><!-- v2023.05.01 -->
    +
    +		<h2>v2022.03.01</h2>
    +		<details> <summary></summary>
    +			<ul class="minimalPadding">
    +				<li>Switched to date-based release names.
    +				<li>Added ability to have your allsky camera added to the
    +					<a external="true" href="http://www.thomasjacquin.com/allsky-map">Allsky Map</a> by configuring
    +					<a external="true"
    +						href="https://github.com/AllskyTeam/allsky/wiki/allsky-Settings/_edit#map-settings">these
    +						settings</a>.
    +					Added <strong>Allsky Map Setup</strong> section to the WebUI to configure the map settings.
    +					The <span class="WebUISetting">Lens</span> field now shows in the popout on the Allsky Website
    +					(if installed).
    +				<li>Significantly enhanced Wiki - more pages and more information on existing pages.
    +					All known issues are described there as well as fixes / workarounds.
    +				<li>Added an option to keograms to make them full width, even if few images were used in creating
    +					the keogram.
    +					In <span class="fileName">config.sh</span>, set <span
    +						class="shSetting">KEOGRAM_EXTRA_PARAMETERS="--image-expand"</span>.
    +				<li>Added/changed/deleted settings (in <span class="fileName">config.sh</span> unless otherwise
    +					noted):
    +					<ul class="minimalPadding">
    +						<li>Added <span class="shSetting">WEBUI_DATA_FILES</span> - contains the name of one or more
    +							files that contain information to
    +							be added to the WebUI's <span class="WebUILink">System</span> page.
    +							See <a external="true"
    +								href="https://github.com/AllskyTeam/allsky/wiki/WEBUI_DATA_FILES">this Wiki
    +								page</a> for more information.
    +						<li>Renamed <span class="shSetting">NIGHTS_TO_KEEP</span> to <span
    +								class="shSetting">DAYS_TO_KEEP</span> since it determines
    +							how many days of data to keep, not just nighttime data.
    +							If blank (<code>""</code>), ALL days' data are kept.
    +						<li>Deleted <span class="shSetting">AUTO_DELETE</span> - its functionality is now in <span
    +								class="shSetting">DAYS_TO_KEEP</span>.
    +							<span class="shSetting">DAYS_TO_KEEP=""</span> is similar to the old <span
    +								class="shSetting">AUTO_DELETE=false</span>.
    +						<li>Added <span class="shSetting">WEB_DAYS_TO_KEEP</span> - specifies how many days of
    +							Allsky Website images and
    +							videos to keep, if the website is installed on your Pi.
    +						<li>Added <span class="shSetting">WEB_IMAGE_DIR</span> in <span
    +								class="fileName">ftp-settings.sh</span> to allow the
    +							images to be copied to a location on your Pi (usually the Allsky Website) as well as
    +							being copied to a remote machine.
    +							This functionality already existed with timelapse, startrails, and keogram files.
    +					</ul>
    +				<li>The RPi camera now supports all the text overlay features as the ZWO camera,
    +					including the <span class="WebUISetting">Extra Text</span> file.
    +				<li>Removed the harmless "deprecated pixel format used" message from the timelapse log file.
    +					That message only confused people.
    +				<li>Improved the auto-exposure for RPi cameras.
    +				<li>Made numerous changes to the ZWO and RPi camera's code that will make it easier to maintain and
    +					add new features in the future.
    +				<li>If Allsky is stopped or restarted while a file is being uploaded to a remote server,
    +					the upload continues, eliminating cases where a temporary file would be left on the server.
    +				<li>Decreased other cases where temporary files would be left on remote servers during uploads.
    +					Also, uploads now perform additional error checking to help in debugging.
    +				<li>Only one upload can now be done at a time.
    +					Any additional uploads display a message in the log file and then exit.
    +					This should eliminate (or signifiantly decrease) cases where a file is overwritten or not found,
    +					resulting in an error message or a temporary file left on the server.
    +				<li>Added a <code>--debug</code> option to <code>allsky/scripts/upload.sh</code> to aid in debugging
    +					uploads.
    +				<li>Upload log files are only created if there was an error; this saves writes to SD cards.
    +				<li>The <code>removeBadImages.sh</code> script also only creates a log file if there was an error,
    +					which saves one write to the SD card <em>for every image</em>.
    +				<li>Allsky now stops with an error message on unrecoverable errors (e.g., no camera found).
    +					It used to keep restarting and failing forever.
    +				<li>More meaningful messages are displayed as images.
    +					For example, in most cases "<strong>ERROR. See /var/log/allsky.log</strong>" messages have been
    +					replaced
    +					with messages containing additional information, for example,
    +					"<strong>*** ERROR *** Allsky Stopped! ZWO camera not found!</strong>".
    +				<li>If Allsky is restarted, a new "Allsky software is restarting" message is displayed,
    +					instead of a "stopping" followed by "starting" message.
    +				<li>The timelapse debug output no longer includes one line for each of several thousand images
    +					proced.
    +					This make it easier to see any actual errors.
    +				<li>The <span class=WebUILink">Camera Settings</span> page of the WebUI now displays the minimum,
    +					maximum,
    +					and default values in a popup for numerical fields.
    +				<li>Startrails and Keogram creation no longer crash if invalid files are found.
    +				<li>Removed the <span class="fileName">allsky/scripts/filename.sh</span> file.
    +				<li>The RPi <span class="WebUISetting">Gamma</span> value in the WebUI was renamed to <span
    +						class="WebUISetting">Saturation</span>,
    +					which is what it always adjusted; <span class="WebUISetting">Gamma</span> was incorrect.
    +				<li>Known issues:
    +					<ul class="minimalPadding">
    +						<li>The startrails and keogram programs don't work well if you bin differently during the
    +							day and night.
    +							If you don't save daytime images this won't be a problem.
    +						<li>The minimum, maximum, and default values in the <span class="WebUILink">Camera
    +								Settings</span> page of the WebUI,
    +							especially for the RPi camera, aren't always correct.
    +							This is especially try if running on the Bullseye operating system, where many of the
    +							settings changed.
    +					</ul>
    +			</ul>
    +		</details>
     
    -			<h2>v2022.03.01</h2>
    -			<details>
    -				<summary></summary>
    -				<ul class="minimalPadding">
    -					<li>Switched to date-based release names.
    -					<li>Added ability to have your allsky camera added to the
    -						<a external="true" href="http://www.thomasjacquin.com/allsky-map">Allsky Map</a> by configuring
    -						<a external="true"
    -							href="https://github.com/AllskyTeam/allsky/wiki/allsky-Settings/_edit#map-settings">these
    -							settings</a>.
    -						Added <strong>Allsky Map Setup</strong> section to the WebUI to configure the map settings.
    -						The <span class="WebUISetting">Lens</span> field now shows in the popout on the Allsky Website
    -						(if installed).
    -					<li>Significantly enhanced Wiki - more pages and more information on existing pages.
    -						All known issues are described there as well as fixes / workarounds.
    -					<li>Added an option to keograms to make them full width, even if few images were used in creating
    -						the keogram.
    -						In <span class="fileName">config.sh</span>, set <span
    -							class="shSetting">KEOGRAM_EXTRA_PARAMETERS="--image-expand"</span>.
    -					<li>Added/changed/deleted settings (in <span class="fileName">config.sh</span> unless otherwise
    -						noted):
    -						<ul class="minimalPadding">
    -							<li>Added <span class="shSetting">WEBUI_DATA_FILES</span> - contains the name of one or more
    -								files that contain information to
    -								be added to the WebUI's <span class="WebUILink">System</span> page.
    -								See <a external="true"
    -									href="https://github.com/AllskyTeam/allsky/wiki/WEBUI_DATA_FILES">this Wiki
    -									page</a> for more information.
    -							<li>Renamed <span class="shSetting">NIGHTS_TO_KEEP</span> to <span
    -									class="shSetting">DAYS_TO_KEEP</span> since it determines
    -								how many days of data to keep, not just nighttime data.
    -								If blank (<code>""</code>), ALL days' data are kept.
    -							<li>Deleted <span class="shSetting">AUTO_DELETE</span> - its functionality is now in <span
    -									class="shSetting">DAYS_TO_KEEP</span>.
    -								<span class="shSetting">DAYS_TO_KEEP=""</span> is similar to the old <span
    -									class="shSetting">AUTO_DELETE=false</span>.
    -							<li>Added <span class="shSetting">WEB_DAYS_TO_KEEP</span> - specifies how many days of
    -								Allsky Website images and
    -								videos to keep, if the website is installed on your Pi.
    -							<li>Added <span class="shSetting">WEB_IMAGE_DIR</span> in <span
    -									class="fileName">ftp-settings.sh</span> to allow the
    -								images to be copied to a location on your Pi (usually the Allsky Website) as well as
    -								being copied to a remote machine.
    -								This functionality already existed with timelapse, startrails, and keogram files.
    -						</ul>
    -					<li>The RPi camera now supports all the text overlay features as the ZWO camera,
    -						including the <span class="WebUISetting">Extra Text</span> file.
    -					<li>Removed the harmless "deprecated pixel format used" message from the timelapse log file.
    -						That message only confused people.
    -					<li>Improved the auto-exposure for RPi cameras.
    -					<li>Made numerous changes to the ZWO and RPi camera's code that will make it easier to maintain and
    -						add new features in the future.
    -					<li>If Allsky is stopped or restarted while a file is being uploaded to a remote server,
    -						the upload continues, eliminating cases where a temporary file would be left on the server.
    -					<li>Decreased other cases where temporary files would be left on remote servers during uploads.
    -						Also, uploads now perform additional error checking to help in debugging.
    -					<li>Only one upload can now be done at a time.
    -						Any additional uploads display a message in the log file and then exit.
    -						This should eliminate (or signifiantly decrease) cases where a file is overwritten or not found,
    -						resulting in an error message or a temporary file left on the server.
    -					<li>Added a <code>--debug</code> option to <code>allsky/scripts/upload.sh</code> to aid in debugging
    -						uploads.
    -					<li>Upload log files are only created if there was an error; this saves writes to SD cards.
    -					<li>The <code>removeBadImages.sh</code> script also only creates a log file if there was an error,
    -						which saves one write to the SD card <em>for every image</em>.
    -					<li>Allsky now stops with an error message on unrecoverable errors (e.g., no camera found).
    -						It used to keep restarting and failing forever.
    -					<li>More meaningful messages are displayed as images.
    -						For example, in most cases "<strong>ERROR. See /var/log/allsky.log</strong>" messages have been
    -						replaced
    -						with messages containing additional information, for example,
    -						"<strong>*** ERROR *** Allsky Stopped! ZWO camera not found!</strong>".
    -					<li>If Allsky is restarted, a new "Allsky software is restarting" message is displayed,
    -						instead of a "stopping" followed by "starting" message.
    -					<li>The timelapse debug output no longer includes one line for each of several thousand images
    -						proced.
    -						This make it easier to see any actual errors.
    -					<li>The <span class=WebUILink">Camera Settings</span> page of the WebUI now displays the minimum,
    -						maximum,
    -						and default values in a popup for numerical fields.
    -					<li>Startrails and Keogram creation no longer crash if invalid files are found.
    -					<li>Removed the <span class="fileName">allsky/scripts/filename.sh</span> file.
    -					<li>The RPi <span class="WebUISetting">Gamma</span> value in the WebUI was renamed to <span
    -							class="WebUISetting">Saturation</span>,
    -						which is what it always adjusted; <span class="WebUISetting">Gamma</span> was incorrect.
    -					<li>Known issues:
    -						<ul class="minimalPadding">
    -							<li>The startrails and keogram programs don't work well if you bin differently during the
    -								day and night.
    -								If you don't save daytime images this won't be a problem.
    -							<li>The minimum, maximum, and default values in the <span class="WebUILink">Camera
    -									Settings</span> page of the WebUI,
    -								especially for the RPi camera, aren't always correct.
    -								This is especially try if running on the Bullseye operating system, where many of the
    -								settings changed.
    -						</ul>
    -				</ul>
    -			</details>
     
    +		<h2>0.8</h2>
    +		<details sub> <summary></summary>
     
    -			<h2>0.8.3</h2>
    -			<details>
    -				<summary></summary>
    +			<h3>0.8.3 - Point Release</h3>
    +			<details sub> <summary></summary>
     				<ul class="minimalPadding">
     					<li>Works on Bullseye operating system.
     					<li>RPi version:
    @@ -735,7 +1034,8 @@ <h2>0.8.3</h2>
     								This helps decrease problems when creating startrails, keograms, and timelapse videos.
     							<li><span class="shSetting">IMG_PREFIX</span>: no longer used - the name of the image used
     								by the websites is now
    -								whatever you specify in the WebUI (default: <span class="fileName">image.jpg</span>).
    +								whatever you specify in the WebUI (default:
    +								<span class="fileName">image.jpg</span>).
     							<li>
     								<blockquote>When upgrading to 0.8.3 you MUST follow the steps listed
     									<a external="true"
    @@ -745,15 +1045,18 @@ <h2>0.8.3</h2>
     					<li>Replaced <code>saveImageDay.sh</code> and <code>saveImageNight.sh</code> with
     						<code>saveImage.sh</code> that has improved functionality,
     						including passing the sensor temperature to the dark subtraction commands,
    -						thereby eliminating the need for the <span class="fileName">temperature.txt</span> file.
    -					<li>The image used by the websites (default: <span class="fileName">image.jpg</span>)
    -						as well as all temporary files are now written to <span class="fileName">allsky/tmp</span>.
    +						thereby eliminating the need for the
    +						<span class="fileName">temperature.txt</span> file.
    +					<li>The image used by the websites (default:
    +						<span class="fileName">image.jpg</span>)
    +						as well as all temporary files are now written to
    +						<span class="fileName">allsky/tmp</span>.
     						<blockquote>If you are using the Allsky Website you will need to change the
     							<span class="editorSetting">imageName</span> variable in
     							<span class="fileName">/var/www/html/allsky/config.js</span> to
     							<span class="fileName">"/current/tmp/image.jpg"</span>.
     						</blockquote>
    -					<li>You can <strong>significanly</strong> reduce wear on your SD card by making <span
    +					<li>You can <strong>significantly</strong> reduce wear on your SD card by making <span
     							class="fileName">allsky/tmp</span>
     						a <a external="true"
     							href="https://github.com/AllskyTeam/allsky/wiki/Miscellaneous-Tips">memory-based
    @@ -762,9 +1065,8 @@ <h2>0.8.3</h2>
     			</details>
     
     
    -			<h2>0.8.1</h2>
    -			<details>
    -				<summary></summary>
    +			<h3>0.8.1 - Point Release</h3>
    +			<details sub> <summary></summary>
     				<ul class="minimalPadding">
     					<li>Rearranged the directory structure.
     					<li>Created a Wiki with additional documentation and troubleshooting tips.
    @@ -790,9 +1092,8 @@ <h2>0.8.1</h2>
     			</details>
     
     
    -			<h2>0.8</h2>
    -			<details>
    -				<summary></summary>
    +			<h3>0.8 Base Release</h3>
    +			<details sub> <summary></summary>
     				<ul class="minimalPadding">
     					<li>Workaround for ZWO daytime autoexposure bug.
     					<li>Improved exposure transitions between day and night so there's not such a huge change in
    @@ -809,97 +1110,91 @@ <h2>0.8</h2>
     					<li>Ability to reset USB bus if ZWO camera isn't found (requires <code>uhubctl</code> command to be
     						installed).
     					<li>Ability to specify the format of the time displayed on images.
    -					<li>Ability to have the temperature displayed in Celcius, Fahrenheit, or both.
    +					<li>Ability to have the temperature displayed in Celsius, Fahrenheit, or both.
     					<li>Ability to set bitrate on timelapse video.
     				</ul>
     			</details>
    +		</details><!-- 0.8 -->
     
     
    -			<h2>0.7</h2>
    -			<details>
    -				<summary></summary>
    -				<ul class="minimalPadding">
    -					<li>Added Raspberry Pi camera HQ support based on Rob Musquetier's fork.
    -					<li>Added support for x86 architecture (Ubuntu, etc.).
    -					<li>Temperature dependant dark frame library.
    -					<li>Added browser-based script editor.
    -					<li>Added configuration variables to crop black area around image.
    -					<li>Added timelapse frame rate setting.
    -					<li>Changed font size default value.
    -				</ul>
    -			</details>
    +		<h2>0.7</h2>
    +		<details> <summary></summary>
    +			<ul class="minimalPadding">
    +				<li>Added Raspberry Pi camera HQ support based on Rob Musquetier's fork.
    +				<li>Added support for x86 architecture (Ubuntu, etc.).
    +				<li>Temperature dependant dark frame library.
    +				<li>Added browser-based script editor.
    +				<li>Added configuration variables to crop black area around image.
    +				<li>Added timelapse frame rate setting.
    +				<li>Changed font size default value.
    +			</ul>
    +		</details>
     
     
    -			<h2>0.6</h2>
    -			<details>
    -				<summary></summary>
    -				<ul class="minimalPadding">
    -					<li>Added daytime exposure and auto-exposure capability.
    -					<li>Added <code>-maxexposure</code>, <code>-autoexposure</code>, <code>-maxgain</code>,
    -						<code>-autogain</code> options.
    -						Note that using autoexposure and autogain at the same time may produce unexpected results (black
    -						frames).
    -					<li>Autostart is now based on systemd and should work on all raspbian based systems, including
    -						headless distributions.
    -						Remote controlling will not start multiple instances of the software.
    -					<li>Replaced <code>nodisplay</code> option with <code>preview</code> argument.
    -						No preview in autostart mode.
    -					<li>When using the WebUI, camera options can be saved without rebooting the RPi.
    -					<li>Added a publicly accessible preview to the WebUI: <span class="fileName">public.php</span>.
    -					<li>Changed exposure unit to milliseconds instead of microseconds.
    -				</ul>
    -			</details>
    +		<h2>0.6</h2>
    +		<details> <summary></summary>
    +			<ul class="minimalPadding">
    +				<li>Added daytime exposure and auto-exposure capability.
    +				<li>Added <code>-maxexposure</code>, <code>-autoexposure</code>, <code>-maxgain</code>,
    +					<code>-autogain</code> options.
    +					Note that using autoexposure and autogain at the same time may produce unexpected results (black
    +					frames).
    +				<li>Autostart is now based on systemd and should work on all raspbian based systems, including
    +					headless distributions.
    +					Remote controlling will not start multiple instances of the software.
    +				<li>Replaced <code>nodisplay</code> option with <code>preview</code> argument.
    +					No preview in autostart mode.
    +				<li>When using the WebUI, camera options can be saved without rebooting the RPi.
    +				<li>Added a publicly accessible preview to the WebUI: <span class="fileName">public.php</span>.
    +				<li>Changed exposure unit to milliseconds instead of microseconds.
    +			</ul>
    +		</details>
     
     
    -			<h2>0.5</h2>
    -			<details>
    -				<summary></summary>
    -				<ul class="minimalPadding">
    -					<li>Added Startrails (image stacking) with brightness control.
    -					<li>Keograms and Startrails generation is now much faster thanks to a rewrite by Jarno Paananen..
    -				</ul>
    -			</details>
    +		<h2>0.5</h2>
    +		<details> <summary></summary>
    +			<ul class="minimalPadding">
    +				<li>Added Startrails (image stacking) with brightness control.
    +				<li>Keograms and Startrails generation is now much faster thanks to a rewrite by Jarno Paananen..
    +			</ul>
    +		</details>
     
     
    -			<h2>0.4</h2>
    -			<details>
    -				<summary></summary>
    -				<ul class="minimalPadding">
    -					<li>Added Keograms (summary of the night in one image).
    -				</ul>
    -			</details>
    +		<h2>0.4</h2>
    +		<details> <summary></summary>
    +			<ul class="minimalPadding">
    +				<li>Added Keograms (summary of the night in one image).
    +			</ul>
    +		</details>
     
     
    -			<h2>0.3</h2>
    -			<details>
    -				<summary></summary>
    -				<ul class="minimalPadding">
    -					<li>Added dark frame subtraction.
    -				</ul>
    -			</details>
    +		<h2>0.3</h2>
    +		<details> <summary></summary>
    +			<ul class="minimalPadding">
    +				<li>Added dark frame subtraction.
    +			</ul>
    +		</details>
     
     
    -			<h2>0.2</h2>
    -			<details>
    -				<summary></summary>
    -				<ul class="minimalPadding">
    -					<li>Separated camera settings from code logic.
    -				</ul>
    -			</details>
    +		<h2>0.2</h2>
    +		<details> <summary></summary>
    +			<ul class="minimalPadding">
    +				<li>Separated camera settings from code logic.
    +			</ul>
    +		</details>
     
     
    -			<h2>0.1</h2>
    -			<details>
    -				<summary></summary>
    -				<ul class="minimalPadding">
    -					<li>Initial release.
    -				</ul>
    -			</details>
    +		<h2>0.1</h2>
    +		<details> <summary></summary>
    +			<ul class="minimalPadding">
    +				<li>Initial release.
    +			</ul>
    +		</details>
     
     
    -		</div><!-- Layout-main -->
    -	</div><!-- Layout -->
    +	</div><!-- Layout-main -->
    +</div><!-- Layout -->
     </body>
     
     </html>
    -<script> includeHTML(); </script>
    +<script> includeHTML(); </script>
    \ No newline at end of file
    diff --git a/html/documentation/css/custom.css b/html/documentation/css/custom.css
    index babd49f4e..21fbf7d5b 100644
    --- a/html/documentation/css/custom.css
    +++ b/html/documentation/css/custom.css
    @@ -1,21 +1,26 @@
     :root {
     	--navbar-background-color: #f8f8f8;
    +	--sticky-background-color: #bbb;
     	--navbar-border-color: #e7e7e7;
     	--navbar-background-color-dark: #272727;
    +	--sticky-background-color-dark: #222;
     	--navbar-border-color-dark: #333333;
     	--WebUI-setting-color: #00009b;
     	--WebUI-setting-color-dark: #68a0ff;
    +	--sticky-border-color: #888;	
    +	--sticky-border-color-dark: #888;	
     	--WebUI-value-color: #555;
     	--WebUI-value-background-color: #e4e4e4;
     	--WebUI-link-color: #4b4a4b;
     	--WebUI-link-background-color: #f8f8f8;
     	--WebUI-subSetting-color: black;
    -	--WebUI-subSetting-background-color: #aac;
    -	--WebUI-subSetting-background-color-dark: #339;
    +	--WebUI-subSetting-background-color: #bbb;
    +	--WebUI-subSetting-background-color-dark: #555;
    +	--WebUI-subSettingNote-background-color: #cce;
     	--dark-text: #aaaaaa;
     	--dark-text-reversed: #eeeeee;
    -	--rowSeparator: 1px solid #d3d3d3;
    -	--rowSeparator-dark: 1px solid #707070;
    +	--rowSeparator: #e3e3e3;
    +	--rowSeparator-dark: #404040;
     	--btn-hover-color: black;
     	--btn-dark-color: #bfbfbf;
     	--panel-heading-color: #6b6b6b;
    @@ -252,7 +257,7 @@ body.dark {
     }
     
     .highlightedBox {
    -	border: 4px dashed red;
    +	border: 2px dashed red;
     	margin-top: 30px;
     	margin-bottom: 30px;
     	padding: 10px 5px 10px 5px;
    @@ -274,7 +279,7 @@ button.close {
     	margin-top: -2px;
     }
     .alert td {
    -	padding: 5px 10px 5px 5px;
    +	padding: 0 10px 0 3px;
     }
     .alert td .important {
     	font-weight: bold;
    @@ -302,6 +307,8 @@ table .alert-dismissable {
     }
     .dark .alert-message, .dark .alert-info {
     	color: #23485a;
    +	border-color: #99ccdd;
    +	background-color: #8b989f;
     }
     .dark .alert-success {
     	color: #95bb85;
    @@ -322,16 +329,6 @@ table .alert-dismissable {
     	border-top-width: 3px;
     	border-bottom-width: 3px;
     }
    -.dark .alert-info {
    -	color: #888888 !important;
    -	background-color: #111111 !important;
    -	border: 1px solid green !important;
    -}
    -.dark .alert-warning {
    -	color: #888888 !important;
    -	background-color: #111111 !important;
    -	border: 1px solid green !important;
    -}
     
     .dark .form-control,
     .dark input[type="text"],
    @@ -523,18 +520,29 @@ input[type="text"].has-error {
     
     /* Headers in Allsky settings */
     .settingsHeader {
    -	background-color: #aaa;
    +	background-color: #ddd;
     	text-align: center;
     	font-weight: bold;
     	padding: 1px 3px 1px 3px;
     }
     .dark .settingsHeader {
    -	background-color: #555;
    +	background-color: #333;
     }
    -table.settingsHeader {
    +table .settingsHeader {	/* In the WebUI */
     	font-size: 1.25em;
     	padding: 3px 3px 3px 3px;
     }
    +.settingsHeaderNote {
    +	background-color: #ccc;
    +	font-weight: normal;
    +	font-size: 0.95em;
    +}
    +.dark .settingsHeaderNote {
    +	background-color: #777;
    +}
    +td:has(.subSettingsHeader) {
    +	padding: 1px 0px 0px 0px;
    +}
     .subSettingsHeader {
     	color: var(--WebUI-subSetting-color);
     	background-color: var(--WebUI-subSetting-background-color);
    @@ -544,17 +552,19 @@ table.settingsHeader {
     }
     div.subSettingsHeader {
     	font-size: 1.05em;
    -	margin: 0 3em 0 3em;
    +	//margin: 0 3em 0 3em;
     	padding: 3px 0px;
     }
     .dark .subSettingsHeader {
    -	margin: 0 3em 0 0em;
    +	background-color: var(--WebUI-subSetting-background-color-dark);
    +	color: var(--dark-text);
     }
    -.dark .subSettingsHeader {
    -	background-color: #111111;
    -	color: green;
    -	margin-top: 10px;
    +.subSettingsHeaderNote {
    +	background-color: var(--WebUI-subSettingNote-background-color);
    +	font-weight: normal;
    +	font-size: 0.95em;
     }
    +
     .headingRow {
     	font-size: 110%;
     	border-top: 10px solid transparent;
    @@ -581,6 +591,8 @@ div.subSettingsHeader {
     }
     .headingTitle {
     	width: 100%;
    +	text-align: left;
    +	padding-left: 20px;
     }
     
     .boxShadow {
    @@ -648,10 +660,7 @@ select {
     /* Error messages */
     .errorMsg, .errorMsgBig { color: red; }
     .errorMsgBig { font-size: 200%; }
    -.errorMsgBox { 
    -	padding-left: 5px; 
    -	padding-right: 5px
    -}
    +.errorMsgBox { border: 2px solid red; padding-left: 5px; padding-right: 5px}
     
     .EXPIRED { color: red; font-weight: bold; }
     .systemPageAdditionsLineType {
    @@ -711,7 +720,7 @@ select {
     	font-weight: bold;
     }
     .dark .WebUISetting {
    -	color: #5cb85c;
    +	color: var(--WebUI-setting-color-dark);
     }
     .WebUIValue {
     	padding: 0 3px 0 3px;
    @@ -752,15 +761,15 @@ select {
     div.sticky {
     	position: -webkit-sticky;
     	position: sticky;
    -	top: 0;
    +	top: 5px;
     	margin-top: 10px;
    -	padding: 10px;
    -	background-color: var(--navbar-background-color);
    -	border: 2px solid var(--WebUI-setting-color);
    +	padding: 10px 0 10px 0;
    +	background: rgba(187,187,187, 1);
    +	border: 2px solid var(--sticky-border-color);
     }
     .dark div.sticky {
    -	background-color: #111111;
    -	border: 1px solid green;
    +	background: rgba(34,34,34, 0.9);
    +	border-color: var(--sticky-border-color-dark);
     }
     #backToTopBtn {
     	display: none; /* Hidden by default */
    @@ -832,8 +841,50 @@ div.sticky {
     }
     .imagesSortOrder {
     	font-size: 60%;
    -
    +}
     .as-nav-toggle {
     	float: left !important;
     	margin-left: 5px;
     }
    +
    +/* Settings Navigation */
    +
    +
    +/* Boostrap 5 emulation (Assumes $spacer as 1rem) */
    +.mt-0 {
    +	margin-top: 0 !important;
    +}
    +.mt-1 {
    +	margin-top: 0.25rem !important;
    +}
    +.mt-2 {
    +	margin-top: 0.5rem !important;
    +}
    +.mt-3 {
    +	margin-top: 1rem !important;
    +}
    +.mt-4 {
    +	margin-top: 1.5rem !important;
    +}
    +.mt-5 {
    +	margin-top: 3rem !important;
    +}
    +
    +.ml-0 {
    +	margin-left: 0 !important;
    +}
    +.ml-1 {
    +	margin-left: 0.25rem !important;
    +}
    +.ml-2 {
    +	margin-left: 0.5rem !important;
    +}
    +.ml-3 {
    +	margin-left: 1rem !important;
    +}
    +.ml-4 {
    +	margin-left: 1.5rem !important;
    +}
    +.ml-5 {
    +	margin-left: 3rem !important;
    +}
    \ No newline at end of file
    diff --git a/html/documentation/css/documentation.css b/html/documentation/css/documentation.css
    index 663bf8f67..6052b9bd9 100644
    --- a/html/documentation/css/documentation.css
    +++ b/html/documentation/css/documentation.css
    @@ -1,6 +1,12 @@
     :root {
     	--main-body-padding: 12px;
     	--Layout-sidebar-width: 250px;
    +	--WebUI-setting-color: #00009b;
    +	--WebUI-setting-color-dark: #68a0ff;
    +	--WebUI-value-color: #555;
    +	--WebUI-value-background-color: #e4e4e4;
    +	--WebUI-link-color: #4b4a4b;
    +	--WebUI-link-background-color: #f8f8f8;
     }
     
     body {
    @@ -138,12 +144,10 @@ body {
     	font-size: inherit;
     }
     .markdown-body h1 {
    -	padding-bottom: .3em;
     	font-size: 2em;
     	border-bottom: 1px solid var(--color-border-muted);
     }
     .markdown-body h2 {
    -	padding-bottom: .3em;
     	font-size: 1.75em;
     	border-bottom: 1px solid var(--color-border-muted);
     }
    @@ -156,6 +160,42 @@ body {
     .markdown-body h5 {
     	font-size: .875em;
     }
    +.markdown-body h1,
    +.markdown-body h2,
    +.markdown-body h3 {
    +	padding-bottom: .15em;
    +	padding-left: .2em;
    +}
    +.markdown-body h1,
    +.markdown-body h2,
    +.markdown-body h3,
    +.markdown-body h4 {
    +	margin-top: 1.5em;
    +	margin-bottom: 10px;
    +	line-height: 1.25;
    +	background-color: var(--color-accent-muted);
    +}
    +.markdown-body h2,
    +.markdown-body h3 {
    +	background-color: var(--color-accent-subtle);
    +}
    +.markdown-body h1 {
    +	border: 1px solid var(--color-accent-emphasis);
    +}
    +.markdown-body h2 {
    +	border: 1px solid var(--color-accent-muted);
    +}
    +/* TODO: want a lighter shade of blue than h2, but there isn't one defined in light.css */
    +.markdown-body h3 {
    +	opacity: 0.7;
    +	margin-right: 5em;
    +	border: 2px solid var(--color-accent-muted);
    +}
    +.markdown-body h4 {
    +	background-color: transparent;
    +	padding-left: 0;
    +	text-decoration: underline;
    +}
     .markdown-body summary h1,
     .markdown-body summary h2,
     .markdown-body summary h3,
    @@ -256,37 +296,6 @@ body {
     	padding: 0 16px;
     	margin-bottom: 16px;
     }
    -.markdown-body h1,
    -.markdown-body h2,
    -.markdown-body h3,
    -.markdown-body h4 {
    -	margin-top: 1.5em;
    -	margin-bottom: 10px;
    -	line-height: 1.25;
    -	background-color: var(--color-accent-muted);
    -	padding: 3px 0 3px 5px;
    -}
    -.markdown-body h2,
    -.markdown-body h3 {
    -	background-color: var(--color-accent-subtle);
    -}
    -.markdown-body h1 {
    -	border: 1px solid var(--color-accent-emphasis);
    -}
    -.markdown-body h2 {
    -	border: 1px solid var(--color-accent-muted);
    -}
    -/* TODO: want a lighter shade of blue than h2, but there isn't one defined in light.css */
    -.markdown-body h3 {
    -	opacity: 0.7;
    -	margin-right: 5em;
    -	border: 2px solid var(--color-accent-muted);
    -}
    -.markdown-body h4 {
    -	background-color: transparent;
    -	padding-left: 0;
    -	text-decoration: underline;
    -}
     
     .markdown-body table {
     	width: 100%;
    @@ -342,6 +351,14 @@ body {
     	margin-top: 5px;
     	margin-bottom: 5px;
     }
    +.markdown-body .subnote {
    +	background-color: var(--color-accent-muted);
    +	color: var(--color-accent-emphasis);
    +	text-align: center;
    +}
    +.markdown-body table td.subnote {
    +	margin: 0 3em 0 3em;
    +}
     .markdown-body table img {
     	background-color: transparent;
     }
    @@ -455,7 +472,7 @@ body {
     }
     .markdown-body pre {
     	word-wrap: normal;
    -	margin-left: 10px;
    +	margin-left: 0px;
     	margin-right: 10px;
     }
     .markdown-body pre code {
    @@ -481,7 +498,7 @@ body {
     .markdown-body pre {
     	margin-top: 6px;
     	margin-bottom: 8px;
    -	padding: 10px;
    +	padding: 4px 10px;
     	overflow: auto;
     	font-size: 85%;
     	line-height: 1.45;
    @@ -617,6 +634,7 @@ b, strong {
     	grid-auto-flow: column;
     	grid-gap: var(--Layout-gutter);
     	margin-top: 24px !important;
    +	margin-bottom: 3em;
     }
     .Layout .Layout-main {
     	grid-column: 1;
    @@ -668,27 +686,25 @@ b, strong {
     }
     .WebUIWebPage, /* old name */
     .WebUILink {	/* link on the WebUI to one of its pages */
    -	padding: 0 .2em;
    -	color: #4b4a4b;
     	font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
    -	background-color: #f8f8f8;
    +	padding: 0 .2em;
    +	color: var(--WebUI-link-color);
    +	background-color: var(--WebUI-link-background-color);
     	border-bottom: 1px solid #e7e7e7;
     }
     .WebUISetting {	/* Matches what's in the WebUI */
     	font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
    -	color: #333;
    -	color:#00009b;
    +	color: var(--WebUI-setting-color);
     	font-weight: bold;
     	font-size: 95%;
     }
     .WebUIValue {
     	padding: 0 3px 0 3px;
     	font-size: 14px;
    -	font-family: "Helvetica Neue", Helvetica, Arial;
     	font-family: inherit;
    -	color: #555;
    +	color: var(--WebUI-value-color);
    +	background-color: var(--WebUI-value-background-color);
     	border: 1px solid #ccc;
    -	background-color: #e4e4e4;
     	border-radius: 4px;
     }
     .shSetting {	/* Matches what's in the WebUI's "Editor" page for *.sh files. */
    @@ -769,8 +785,17 @@ b, strong {
     .position-relative {
     	position: relative !important;
     }
    -.markdown-body .minimalPadding li {
    -	margin-top: 0;
    +.markdown-body .mediumPadding li {
    +	margin-top: 0.25em;
    +}
    +.markdown-body .minimalPadding {
    +	margin-top: -0.8em;
    +}
    +.markdown-body .minimalPadding.topPadding li:first-child {
    +	padding-top: 0.75em;
    +}
    +.markdown-body .minimalPadding li:not(:first-child) {
    +	margin-top: 0em;
     }
     .markdown-body .morePadding {
     	margin-top: 1.75em !important;
    @@ -929,3 +954,30 @@ b, strong {
     .green {
     	color: green;
     }
    +
    +.possibleAction {
    +	font-weight: bold;
    +	text-decoration-line: underline;
    +	margin-right: 10px;
    +}
    +
    +.gitHubLink {
    +	color: rgb(31, 25, 40);
    +	background-color: #f7f8fa;
    +	border: 1px solid #d0d7de;
    +	border-radius: 3px;
    +	padding: 0 3px 0 3px;
    +	font-family: "Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";
    +}
    +
    +.module-info {
    +	/* What should be here? */
    +}
    +.module-settings {
    +	/* What should be here? */
    +}
    +.module-settings img, .module-info img {
    +	display: block;
    +	margin-left: auto;
    +	margin-right: auto;
    +}
    diff --git a/html/documentation/css/timeline.css b/html/documentation/css/timeline.css
    deleted file mode 100644
    index 92161ebe7..000000000
    --- a/html/documentation/css/timeline.css
    +++ /dev/null
    @@ -1,180 +0,0 @@
    -.timeline {
    -    position: relative;
    -    padding: 20px 0 20px;
    -    list-style: none;
    -}
    -
    -.timeline:before {
    -    content: " ";
    -    position: absolute;
    -    top: 0;
    -    bottom: 0;
    -    left: 50%;
    -    width: 3px;
    -    margin-left: -1.5px;
    -    background-color: #eeeeee;
    -}
    -
    -.timeline > li {
    -    position: relative;
    -    margin-bottom: 20px;
    -}
    -
    -.timeline > li:before,
    -.timeline > li:after {
    -    content: " ";
    -    display: table;
    -}
    -
    -.timeline > li:after {
    -    clear: both;
    -}
    -
    -.timeline > li:before,
    -.timeline > li:after {
    -    content: " ";
    -    display: table;
    -}
    -
    -.timeline > li:after {
    -    clear: both;
    -}
    -
    -.timeline > li > .timeline-panel {
    -    float: left;
    -    position: relative;
    -    width: 46%;
    -    padding: 20px;
    -    border: 1px solid #d4d4d4;
    -    border-radius: 2px;
    -    -webkit-box-shadow: 0 1px 6px rgba(0,0,0,0.175);
    -    box-shadow: 0 1px 6px rgba(0,0,0,0.175);
    -}
    -
    -.timeline > li > .timeline-panel:before {
    -    content: " ";
    -    display: inline-block;
    -    position: absolute;
    -    top: 26px;
    -    right: -15px;
    -    border-top: 15px solid transparent;
    -    border-right: 0 solid #ccc;
    -    border-bottom: 15px solid transparent;
    -    border-left: 15px solid #ccc;
    -}
    -
    -.timeline > li > .timeline-panel:after {
    -    content: " ";
    -    display: inline-block;
    -    position: absolute;
    -    top: 27px;
    -    right: -14px;
    -    border-top: 14px solid transparent;
    -    border-right: 0 solid #fff;
    -    border-bottom: 14px solid transparent;
    -    border-left: 14px solid #fff;
    -}
    -
    -.timeline > li > .timeline-badge {
    -    z-index: 100;
    -    position: absolute;
    -    top: 16px;
    -    left: 50%;
    -    width: 50px;
    -    height: 50px;
    -    margin-left: -25px;
    -    border-radius: 50% 50% 50% 50%;
    -    text-align: center;
    -    font-size: 1.4em;
    -    line-height: 50px;
    -    color: #fff;
    -    background-color: #999999;
    -}
    -
    -.timeline > li.timeline-inverted > .timeline-panel {
    -    float: right;
    -}
    -
    -.timeline > li.timeline-inverted > .timeline-panel:before {
    -    right: auto;
    -    left: -15px;
    -    border-right-width: 15px;
    -    border-left-width: 0;
    -}
    -
    -.timeline > li.timeline-inverted > .timeline-panel:after {
    -    right: auto;
    -    left: -14px;
    -    border-right-width: 14px;
    -    border-left-width: 0;
    -}
    -
    -.timeline-badge.primary {
    -    background-color: #2e6da4 !important;
    -}
    -
    -.timeline-badge.success {
    -    background-color: #3f903f !important;
    -}
    -
    -.timeline-badge.warning {
    -    background-color: #f0ad4e !important;
    -}
    -
    -.timeline-badge.danger {
    -    background-color: #d9534f !important;
    -}
    -
    -.timeline-badge.info {
    -    background-color: #5bc0de !important;
    -}
    -
    -.timeline-title {
    -    margin-top: 0;
    -    color: inherit;
    -}
    -
    -.timeline-body > p,
    -.timeline-body > ul {
    -    margin-bottom: 0;
    -}
    -
    -.timeline-body > p + p {
    -    margin-top: 5px;
    -}
    -
    -@media(max-width:767px) {
    -    ul.timeline:before {
    -        left: 40px;
    -    }
    -
    -    ul.timeline > li > .timeline-panel {
    -        width: calc(100% - 90px);
    -        width: -moz-calc(100% - 90px);
    -        width: -webkit-calc(100% - 90px);
    -    }
    -
    -    ul.timeline > li > .timeline-badge {
    -        top: 16px;
    -        left: 15px;
    -        margin-left: 0;
    -    }
    -
    -    ul.timeline > li > .timeline-panel {
    -        float: right;
    -    }
    -
    -    ul.timeline > li > .timeline-panel:before {
    -        right: auto;
    -        left: -15px;
    -        border-right-width: 15px;
    -        border-left-width: 0;
    -    }
    -
    -    ul.timeline > li > .timeline-panel:after {
    -        right: auto;
    -        left: -14px;
    -        border-right-width: 14px;
    -        border-left-width: 0;
    -    }
    -}
    \ No newline at end of file
    diff --git a/html/documentation/explanations/EnterNetworkCredentials.png b/html/documentation/explanations/EnterNetworkCredentials.png
    new file mode 100644
    index 000000000..b8d52ad7e
    Binary files /dev/null and b/html/documentation/explanations/EnterNetworkCredentials.png differ
    diff --git a/html/documentation/explanations/Map_network_drive.png b/html/documentation/explanations/Map_network_drive.png
    new file mode 100644
    index 000000000..b28e54fb7
    Binary files /dev/null and b/html/documentation/explanations/Map_network_drive.png differ
    diff --git a/html/documentation/explanations/PiImager.png b/html/documentation/explanations/PiImager.png
    new file mode 100644
    index 000000000..10f2e7ccc
    Binary files /dev/null and b/html/documentation/explanations/PiImager.png differ
    diff --git a/html/documentation/explanations/Pi_network_drive.png b/html/documentation/explanations/Pi_network_drive.png
    new file mode 100644
    index 000000000..f52558e7b
    Binary files /dev/null and b/html/documentation/explanations/Pi_network_drive.png differ
    diff --git a/html/documentation/explanations/SAMBA.html b/html/documentation/explanations/SAMBA.html
    new file mode 100644
    index 000000000..2445734fb
    --- /dev/null
    +++ b/html/documentation/explanations/SAMBA.html
    @@ -0,0 +1,88 @@
    +<!DOCTYPE html>
    +<html lang="en">
    +<head>
    +	<meta charset="utf-8">
    +	<meta http-equiv="X-UA-Compatible" content="IE=edge">
    +	<meta name="viewport" content="width=device-width, initial-scale=1">
    +    <script src="../js/documentation.js" type="application/javascript"></script>
    +    <link href="../css/light.css" rel="stylesheet">
    +	<link href="../bower_components/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
    +	<script src="../bower_components/jquery/dist/jquery.min.js"></script>
    +	<script src="../bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
    +	<style>
    +		#pageTitle::before {
    +			content: "Copy files to/from a Pi";
    +		} 
    +	</style>
    +    <link href="../css/documentation.css" rel="stylesheet">
    +    <link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<title>SAMBA</title>
    +</head>
    +<body>
    +<div w3-include-html="/documentation/pageHeader.html" id="pageHeader"></div>
    +<div class="Layout">
    +<div class="Layout-sidebar" w3-include-html="/documentation/sidebar.html" id="sidebar"></div>
    +<div class="Layout-main markdown-body" id="mainContents">
    +
    +<p>
    +This page describes how to copy files to and from the Pi.
    +</p>
    +
    +<h3>Small text files</h3>
    +<p>
    +If the file you want to copy is a text file and it fits on one screen
    +you can simply highlight the text and copy to the clipboard,
    +then paste it into a file on your PC or Mac.
    +</p>
    +<h3>Other files</h3>
    +<p>
    +For all other files you can mount the Pi's
    +<span class="fileName">/home/pi</span> directory
    +(or the user you installed Allsky as) onto your PC or Mac using the SAMBA service.
    +<p>
    +</p>
    +The first step is to install SAMBA on the Pi:
    +<pre>
    +allsky-config  samba
    +</pre>
    +Follow the prompts.
    +</p>
    +<p>
    +When installation is done you should see something like this
    +on a PC in Windows File Explorer, where <code>ALLSKY</code> is the name of the Pi:
    +<br>
    +<img src="Pi_network_drive.png" title="Pi network drive" class="imgBorder imgCenter">
    +</p>
    +<p>
    +To mount this directory on a PC:
    +<ol>
    +	<li>Right-click the "pi_home" icon and select <strong>Map network drive...</strong>.
    +		<br>You'll see something like:
    +		<br>
    +		<img src="Map_network_drive.png" title="Map network drive" class="imgBorder imgCenter">
    +	<li>Pick any drive letter you want.
    +	<li>Check the <strong>Connect using different credentials</strong> box.
    +	<li>Click the <code>Finish</code> button.
    +		You'll then see a dialog box like this:
    +		<br>
    +		<img src="EnterNetworkCredentials.png" title="Enter network credentials" class="imgBorder imgCenter">
    +	<li>For the <code>User name</code>,
    +		enter <code>WORKGROUP\pi</code>
    +		(replacing <code>pi</code> with the login you installed Allsky as).
    +		<br>
    +	<li>Use the SAMBA password you entered during installation.
    +	<li>Check the <code>Remember my credentials</code> box so you don't have
    +		to manually log in every time.
    +	<li>Click OK.
    +</ol>
    +
    +<p>
    +SAMBA only needs to be installed once unless you reimage your SD card.
    +</p>
    +
    +
    +</div><!-- Layout-main -->
    +</div><!-- Layout -->
    +</body>
    +</html>
    +<script> includeHTML(); </script>
    diff --git a/html/documentation/explanations/SSL.html b/html/documentation/explanations/SSL.html
    index 4add49a4e..a87080f54 100644
    --- a/html/documentation/explanations/SSL.html
    +++ b/html/documentation/explanations/SSL.html
    @@ -16,6 +16,7 @@
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
     	<title>Using SSL</title>
     </head>
     <body>
    @@ -33,18 +34,22 @@
     
     <h4>Requirements</h4>
     <ol class="minimalPadding">
    -	<li>You must have Allsky working on your Pi prior to installing SSL.
    +	<li>Allsky must be working on your Pi prior to installing SSL.
     	<li>You must already have an Internet domain name for your Pi.
    -		The instructions below use <b>myallsky.com</b> but
    +		The instructions below use <b>www.myallsky.com</b> but
     		replace that with your domain name.
    +	<li>We assume that you used the default Pi user to install Allsky in <b>/home/pi</b>,
    +		so adapt the path to your needs.
    +	<li>You must forward the ports 80 and 443 to your Raspberry Pi
    +		private IP address on your router.
    +		
     </ol>
     
    -
     <blockquote class="warning morePadding">
     Installing SSL requires that your Pi be accessible via the Internet and
     can be a <strong>huge</strong> security risk if not done correctly.
     <br><br>
    -Do not attempt unless you know what you are doing.
    +<strong>Do not attempt unless you know what you are doing.</strong>
     The Allsky developers are not responsible for any loss or damages.
     <br><br>
     There are additional steps you should take to secure your Pi that aren't
    @@ -59,136 +64,209 @@ <h4>Requirements</h4>
     
     <p class="morePadding">
     There are many ways to implement SSL;
    -the instructions below use
    -<a external="true" href="https://letsencrypt.org/">Let's Encrypt SSL</a>
    -which is popular and free software package.
    +the instructions below use the popular and free
    +<a external="true" href="https://letsencrypt.org/">Let's Encrypt SSL</a>.
     </p>
     
     
     <h4>Software installation</h4>
    -Install some software on your Pi
    +<p>
    +Install the following software on your Pi
     (you can copy/paste these lines into a terminal window):
     <pre>
    -apt-get update
    -apt-get install software-properties-common
    -add-apt-repository ppa:certbot/certbot
    -apt-get update
    -apt-get install certbot
    -mkdir ~/allsky/config/ssl         <span class="shellComment"></span>
    -chmod 775 ~/allsky/config/ssl
    -
    -certbot certonly --webroot -w /config/ssl/<b>myallsky.com</b> -d <b>myallsky.com</b>
    +sudo apt update
    +sudo apt install software-properties-common certbot
    +mkdir -p ~/allsky/config/ssl/<b>www.myallsky.com</b>
    +chmod -R 775 ~/allsky/config/ssl
     </pre>
    +</p>
     
     <p class="morePadding">
    -The <code>cerbot</code> command will prompt for your email and a couple other things.
    -It will then create the SSL certificate and private key.
    -Take note of the IMPORTANT NOTES that are displayed.
    +<blockquote>
    +	If you are not using the default user name of <b>pi</b>, run <code>whoami</code>
    +	to determine your user name,
    +	then replace all occurances of <code><b>pi</b></code>
    +	in the commands below with your user name.
    +</blockquote>
    +
    +In your favorite text editor create a file called
    +<span class="fileName">/etc/lighttpd/conf-enabled/97-certbot.conf</span>.
    +For example with the <code>nano</code> text editor:
    +<pre>
    +sudo nano /etc/lighttpd/conf-enabled/97-certbot.conf
    +</pre>
    +
    +Add these lines to the file:
    +<pre>
    +$HTTP["url"] =~ "^/.well-known/" {
    +    alias.url += ( "/.well-known/" =&gt; "<b>/home/pi</b>/allsky/config/ssl/<b>www.myallsky.com</b>/.well-known/" )
    +}
    +</pre>
    +Save the file with <b>Ctrl+x</b>.
    +</p>
    +
    +<p>
    +Restart the <b>lighttpd</b> web service to use the new configuration:
    +<pre>
    +sudo systemctl restart lighttpd.service
    +</pre>
    +</p>
    +
    +
    +<h4>Certificates generation</h4>
    +<p>
    +The <code>cerbot</code> command will prompt for registration and
    +create the SSL certificate and private key.
    +Replace <b>x@y.com</b> with your email:
    +<pre>
    +sudo certbot certonly --webroot -w allsky/config/ssl/<b>www.myallsky.com</b> \
    +    -d <b>www.myallsky.com</b> -m <b>x@y.com</b> --agree-tos
    +</pre>
     </p>
     <p>
     Combine the certificate and private key into one file:
     <pre>
    -sudo chmod 750 /etc/letsencrypt/live
    -cd /etc/letsencrypt/live/<b>myallsky.com</b>
    -cat cert.pem privkey.pem | sudo tee fullchain.pem
    +sudo cat /etc/letsencrypt/live/<b>www.myallsky.com</b>/cert.pem \
    +    /etc/letsencrypt/live/<b>www.myallsky.com</b>/privkey.pem | \
    +    sudo tee /etc/letsencrypt/live/<b>www.myallsky.com</b>/fullchain.pem
     </pre>
     </p>
     
     
     <h4>Web server configuration</h4>
    -Configure the web server (called <b>lighttpd</b> to use SSL and redirects any
    -"http" requests to your Pi with "https" requests.
     <p>
    -In your favorite text editor,
    -create a file called <span class="fileName">98-<b>myallsky.com</b>.conf</span>
    -containing the following lines:
    +Configure <b>lighttpd</b> to use SSL and redirect any "http" requests on your
    +Pi to "https" requests.
    +<br>Create a file called
    +<span class="fileName">/etc/lighttpd/conf-enabled/98-<b>www.myallsky.com></b>.conf</span>:
    +<pre>
    +sudo nano /etc/lighttpd/conf-enabled/98-<b>www.myallsky.com</b>.conf
    +</pre>
    +</p>
    +
    +<p>
    +Add these lines to the file:
     <pre>
     server.modules += ( "mod_openssl" )
     
     $SERVER["socket"] == ":443" {
    -	ssl.engine = "enable"
    -	ssl.pemfile = "/etc/letsencrypt/live/<b>myallsky.com</b>/fullchain.pem"
    -	ssl.ca-file = "/etc/letsencrypt/live/<b>myallsky.com</b>/privkey.pem"
    -	server.name = "<b>myallsky.com</b>"
    +        ssl.engine = "enable"
    +        ssl.pemfile = "<b>/home/pi</b>/fullchain.pem"
    +        ssl.ca-file = "<b>/home/pi</b>/chain.pem"
    +        ssl.privkey = "<b>/home/pi</b>/privkey.pem"
    +        server.name = "<b>www.myallsky.com</b>"
     
    -	# Uncomment the next line if you want the web server to log access requests.
    -	# accesslog.filename = "/var/log/lighttpd/<b>myallsky.com</b>_access.log"
    +        # Uncomment the next line if you want the web server to log access requests.
    +        # accesslog.filename = "/var/log/lighttpd/<b>www.myallsky.com</b>_access.log"
     }
     
     $HTTP["scheme"] == "http" {
    -	$HTTP["host"] == "<b>myallsky.com</b>" {
    -		url.redirect = ("" =&gt; "https://${url.authority}${url.path}${qsa}")
    -		url.redirect-code = 308
    -	}
    +        $HTTP["host"] == "<b>www.myallsky.com</b>" {
    +                url.redirect = ("" =&gt; "https://${url.authority}${url.path}${qsa}")
    +                url.redirect-code = 308
    +        }
     }
     </pre>
     </p>
     
     <p class="morePadding">
    -Install the file so the web server sees it:
     <pre>
    -sudo install -m 0644  98-<b>myallsky.com</b>.conf  /etc/lighttpd/conf-available
    -cd /etc/lighttpd/conf-enabled
    -sudo ln -s  "../conf-available/98-<b>myallsky.com</b>.conf"  .
    -sudo systemctl restart lighttpd
    +cd
    +sudo cp /etc/letsencrypt/live/<b>www.myallsky.com</b>/{privkey.pem,fullchain.pem,chain.pem} .
    +sudo chown <b>pi</b>:<b>pi</b> privkey.pem fullchain.pem chain.pem
    +sudo chmod 644 privkey.pem fullchain.pem chain.pem
    +sudo systemctl restart lighttpd.service
     </pre>
     </p>
     
     
     <h4>Test</h4>
    -Try to access your Pi via
    -<code>http://<b>myallsky.com</b></code>
    +Try to access your Pi via both
    +<code>http://<b>www.myallsky.com></b></code>
     and
    -<code>https://<b>myallsky.com</b></code>.
    +<code>https://<b>www.myallsky.com></b></code>.
     You should see the WebUI in both cases.
     </p>
     
     
    -<h4>Post-installation steps</h4>
    +<h4>Post-installation steps (Renewal process)</h4>
     Let's Encrypt certificates are only valid for 90 days.
     You need to configure the Pi to renew your certificates before they expire
     (or do it manually).
     
     <p>
    -First, run this command to simulate automatic renewal of your certificate:
    +First, renew your certificate, replacing <b>x@y.com</b> with your email:
     <pre>
    -certbot renew --dry-run
    +sudo certbot certonly --webroot -w allsky/config/ssl/<b>www.myallsky.com</b> \
    +    -d <b>www.myallsky.com</b> -m <b>x@y.com</b> --agree-tos
     </pre>
    -<p>
    -You should see output similar to this:
    +You should see output similar to below:
     <pre>
     Saving debug log to /var/log/letsencrypt/letsencrypt.log
    --------------------------------------------------------------------------------
    -Processing /etc/letsencrypt/renewal/myallsky.com.conf
    --------------------------------------------------------------------------------
    -Cert not due for renewal, but simulating renewal for dry run
    -Starting new HTTPS connection (1): acme-staging.api.letsencrypt.org
    -Renewing an existing certificate
    +Plugins selected: Authenticator webroot, Installer None
    +Cert not yet due for renewal
    +
    +You have an existing certificate that has exactly the same domains or certificate name you requested and isn't close to expiry.
    +(ref: /etc/letsencrypt/renewal/<b>www.myallsky.com</b>.conf)
    +
    +What would you like to do?
    +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    +1: Keep the existing certificate for now
    +2: Renew &amp; replace the certificate (may be subject to CA rate limits)
    +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    +Select the appropriate number [1-2] then [enter] (press 'c' to cancel):
    +</pre>
    +
    +Select option <b>2</b>.
    +You will then see:
    +<pre>
    +Renewing an existing certificate for <b>www.myallsky.com</b>
     Performing the following challenges:
    -http-01 challenge for myallsky.com
    +http-01 challenge for allsky02.my-cosi.info
    +Using the webroot path /home/pi/allsky/config/ssl/<b>www.myallsky.com</b>.info for all unmatched domains.
     Waiting for verification...
     Cleaning up challenges
    --------------------------------------------------------------------------------
    -new certificate deployed without reload, fullchain is
    -/etc/letsencrypt/live/myallsky.com/fullchain.pem
    --------------------------------------------------------------------------------
    -** DRY RUN: simulating 'certbot renew' close to cert expiry
    -**          (The test certificates below have not been saved.)
    -Congratulations, all renewals succeeded. The following certs have been renewed:
    -  /etc/letsencrypt/live/myallsky.com/fullchain.pem (success)
    -** DRY RUN: simulating 'certbot renew' close to cert expiry
    -**          (The test certificates above have not been saved.)
    +
    +IMPORTANT NOTES:
    + - Congratulations! Your certificate and chain have been saved at:
    +   /etc/letsencrypt/live/<b>www.myallsky.com</b>/fullchain.pem
    +   Your key file has been saved at:
    +   /etc/letsencrypt/live/<b>www.myallsky.com</b>/privkey.pem
    +   Your certificate will expire on 2024-09-07. To obtain a new or
    +   tweaked version of this certificate in the future, simply run
    +   certbot again. To non-interactively renew *all* of your
    +   certificates, run "certbot renew"
    + - If you like Certbot, please consider supporting our work by:
    +
    +   Donating to ISRG / Let's Encrypt:   https://letsencrypt.org/donate
    +   Donating to EFF:                    https://eff.org/donate-le
    +</pre>
    +</p>
    +
    +<p>
    +Now combine the certificate and private key created above into one file:
    +<pre>
    +sudo cat /etc/letsencrypt/live/<b>www.myallsky.com</b>/cert.pem \
    +    /etc/letsencrypt/live/<b>www.myallsky.com</b>/privkey.pem | \
    +    sudo tee /etc/letsencrypt/live/<b>www.myallsky.com</b>/fullchain.pem
     </pre>
     </p>
     
     <p class="morePadding">
    -Prior the the certificates expiring, run:
    +Now copy the files to your home directory
    +and restart the web server to use the new configuration certs files:
     <pre>
    -certbot renew
    +cd
    +sudo cp /etc/letsencrypt/live/<b>www.myallsky.com</b>/{privkey.pem,fullchain.pem,chain.pem} .
    +sudo chown <b>pi</b>:<b>pi</b> privkey.pem fullchain.pem chain.pem
    +sudo chmod 644 privkey.pem fullchain.pem chain.pem
    +sudo systemctl restart lighttpd.service
     </pre>
     <blockquote class="morePadding">
     You can renew your Let's Encrypt certificates <strong>at most</strong>
     30 days before they expire - two weeks prior to expiration is a good goal.
    +<br>
    +You will also receive an email from letsencrypt before your certificates expire.
     </blockquote>
     </p>
     
    @@ -198,22 +276,19 @@ <h4>Notes</h4>
     	<li>Your SSL-related files are stored in
     		<span class="fileName">~/allsky/config/ssl</span> and will be
     		preserved across Allsky upgrades.
    -	<li>When you are done installing and testing SSL, make a copy of the following
    +	<li>When you are done installing and testing SSL, make a copy of the following items
     		and store on something other than your Pi in case your Pi crashes:
     		<ul class="minimalPadding">
     			<li><span class="fileName">/etc/letsencrypt</span> directory
     			<li><span class="fileName">~/allsky/config/ssl</span> directory
    -			<li><span class="fileName">/etc/lighttpd/conf-available/98-<b>myallsky.com</b>.conf</span>
    +			<li><span class="fileName">/etc/lighttpd/conf-available/97-<b>certbot</b>.conf</span>
    +			<li><span class="fileName">/etc/lighttpd/conf-available/98-<b>www.myallsky.com</b>.conf</span>
    +			<li><span class="fileName">/home/pi/fullchain.pem</span>
    +            <li><span class="fileName">/home/pi/chain.pem</span>
    +            <li><span class="fileName">/home/pi/privkey.pem</span>
     		</ul>
     </ul>
     
    -
    -<h4>Source of instructions</h4>
    -These instruction were obtain from
    -<a external="true" href="https://www.itzgeek.com/how-tos/linux/how-to-configure-lets-encrypt-ssl-in-lighttpd-server.html">https://www.itzgeek.com</a>
    -and modified for Allsky use.
    -
    -
     </div><!-- Layout-main -->
     </div><!-- Layout -->
     </body>
    diff --git a/html/documentation/explanations/SystemPageAdditions.html b/html/documentation/explanations/SystemPageAdditions.html
    index 8c8e45055..791e57b61 100644
    --- a/html/documentation/explanations/SystemPageAdditions.html
    +++ b/html/documentation/explanations/SystemPageAdditions.html
    @@ -11,13 +11,13 @@
     	<script src="../bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
     	<style>
     		#pageTitle::before {
    -			content: "WEBUI_DATA_FILES";
    +			content: "System Page Additions";
     		} 
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
     	<script src="../js/all.min.js" type="application/javascript"></script>
    -	<title>WEBUI_DATA_FILES Tutorial</title>
    +	<title>System Page Additions Tutorial</title>
     </head>
     <body>
     <div w3-include-html="/documentation/pageHeader.html" id="pageHeader"></div>
    @@ -32,7 +32,7 @@
     without having to change any Allsky files.
     The data can be anything you want, but often contains weather, dew heater, and/or fan data.
     </p>
    -This page assumes you are familiar with writing scripts;
    +This documentation page assumes you are familiar with writing scripts;
     if so, adding the information is straightforward
     if you follow the instructions on this page.
     
    @@ -52,8 +52,7 @@ <h2 id="Details">Details</h2>
     <p>
     The information to display on the <span class="WebUIWebPage">System</span> page
     is described in one or more "data files"
    -which are listed in the <span class="shSetting">WEBUI_DATA_FILES</span> setting
    -in <span class="fileName">config.sh</span>
    +which are listed in the <span class="WebUISetting">System Page Additions</span> setting.
     This section describes the format of those data files.
     <blockquote>
     <b>You</b> need to provide the script(s) to create the data files.
    @@ -75,8 +74,8 @@ <h2 id="Details">Details</h2>
     each button such as color and what it should say (e.g., "Heater ON").
     The exact contents of those lines are described below.
     The buttons appear on the <span class="WebUILink">System</span> page
    -when you set <code>WEBUI_DATA_FILES="/home/pi/my_buttons.txt"</code>
    -in <span class="fileName">config.sh</span>.
    +when you set the <span class="WebUISetting">System Page Additions</span> setting
    +to <span class="WebUIValue">/home/pi/my_buttons.txt</span>.
     </p>
     
     <p>
    @@ -400,11 +399,10 @@ <h4>Determine what the buttons should look like</h4>
     You pick the "random" icon so set the button's <b>FA icon</b> field to <code>random</code>.
     </p>
     
    -<h4 id="WEBUI_DATA_FILES">Specify the data files</h4>
    +<h4 id="SystemPageAdditions">Specify the data files</h4>
     You need to enter the names of your data files (i.e., the files created by your scripts) in the
    -<span class="shSetting">WEBUI_DATA_FILES</span> setting of the
    -<span class="fileName">config.sh</span> file,
    -but how many data files should you have?
    +<span class="WebUISetting">System Page Additions</span> setting.
    +But how many data files should you have?
     You could put everything in one file but you have two scripts that create data files
     and would need to coordinate between them so they don't overwrite each other.
     You decide on three data files containing:
    @@ -418,9 +416,10 @@ <h4 id="WEBUI_DATA_FILES">Specify the data files</h4>
     script you put it in a 3rd file that will never change.
     You call the 3rd file <span class="fileName">/home/pi/dewheater/button.txt</span>.
     <p class="morePadding">
    -This is what you use:
    -<pre>
    -WEBUI_DATA_FILES="/home/pi/weather/weatherdata.txt:/home/pi/dewheater/status.txt:/home/pi/dewheater/button.txt"
    +This is what you enter into the
    +<span class="WebUISetting">System Page Additions</span> setting.
    +<pre class="WebUIValue">
    +/home/pi/weather/weatherdata.txt:/home/pi/dewheater/status.txt:/home/pi/dewheater/button.txt
     </pre>
     </p>
     <blockquote>
    @@ -434,7 +433,7 @@ <h4 id="WEBUI_DATA_FILES">Specify the data files</h4>
     <span class="WebUIWebPage">System</span> page;
     those in the second file will appear second, etc.
     You can change the order items appear by changing the order of data file names in the
    -<span class="shSetting">WEBUI_DATA_FILES</span> setting.
    +<span class="WebUISetting">System Page Additions</span> setting.
     </p>
     
     <h4>Create/modify scripts to update the data file(s)</h4>
    @@ -567,26 +566,28 @@ <h4>Test, test, test. Then test some more.</h4>
     and <span class="fileName">/home/pi/dewheater/button.txt</span> files on your Pi.
     Then add
     <p>
    -<pre>
    -WEBUI_DATA_FILES="/home/pi/weather/weatherdata.txt:/home/pi/dewheater/status.txt
    +<pre class="WebUIValue">
    +/home/pi/weather/weatherdata.txt:/home/pi/dewheater/status.txt
     </pre>
    +to the <span class="WebUISetting">System Page Additions</span> setting.
     </p>
    -to <span class="fileName">config.sh</span> via the WebUI's
    -<span class="WebUIWebPage">Editor</span> page.
     Now, go to the <span class="WebUIWebPage">System</span> page in the WebUI
     and in addition to the normal information you should see the items you added.
     Refreshing the screen won't change those items since the data files are not being updated yet.
     
     <p>
    -Clicking the "Toggle dew heater" button should display a red message at the top of the web page that says "'/home/pi/dewheater/toggleDewHeater.py' failed: sh: 1: /home/pi/dewheater/toggleDewHeater.py: not found".
    -That's the output when the web page tried to execute '/home/pi/dewheater/toggleDewHeater.py' which doesn't exist.
    +Clicking the "Toggle dew heater" button should display a red message
    +at the top of the web page that says
    +<code>'/home/pi/dewheater/toggleDewHeater.py' failed: sh: 1: /home/pi/dewheater/toggleDewHeater.py: not found"</code>
    +That's the output when the web page tried to execute
    +<span class='fileName'>/home/pi/dewheater/toggleDewHeater.py</span> which doesn't exist.
     </p>
     
     <p>
     Try making changes to the three files you created.
     For example, change the <b>command</b> for the button to <code>echo 'Hello, world!'</code>.
     When you next click on the button you should see a green message at the top saying "Hello, world!".
    -Now change it to "echo 'Hello, world!' ; exit 1".
    +Now change it to <code>echo 'Hello, world!' ; exit 1</code>.
     What happens now?
     </p>
     
    @@ -601,10 +602,10 @@ <h2 id="Tips">Tips</h2>
     <ul>
     	<li>Do not store your data files in the <span class="fileName">allsky</span> directory
     		since they won't be saved when upgrading Allsky.
    -		It's suggested to create a directory one level above
    +		Instead, create a directory one level above
     		<span class="fileName">allsky</span> and put all your files there.
    -		The <span class="shSetting">WEBUI_DATA_FILES</span> setting WILL be
    -		saved when upgrading Allsky.
    +		The <span class="WebUISetting">System Page Additions</span> setting
    +		WILL be saved when upgrading Allsky.
     </ul>
     </p>
     
    diff --git a/html/documentation/explanations/allskyDirectory.png b/html/documentation/explanations/allskyDirectory.png
    index fef24e152..e84fd345b 100644
    Binary files a/html/documentation/explanations/allskyDirectory.png and b/html/documentation/explanations/allskyDirectory.png differ
    diff --git a/html/documentation/explanations/angleSunriseSunset.html b/html/documentation/explanations/angleSunriseSunset.html
    index d671283bf..cdac2efe7 100644
    --- a/html/documentation/explanations/angleSunriseSunset.html
    +++ b/html/documentation/explanations/angleSunriseSunset.html
    @@ -16,6 +16,7 @@
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
     	<title>Angle, Sunrise, and Sunset</title>
     </head>
     <body>
    @@ -30,24 +31,27 @@ <h2>What does the <span class="WebUISetting">Angle</span> setting do?
     The time the sun actually sets and rises at your location is
     dependent on the day of the year and your latitude and longitude,
     and defines what people call <strong>daytime</strong> and <strong>nighttime</strong>.
    -
    -Allsky also has a <strong>daytime</strong> and <strong>nighttime</strong>
    -that determine when to use your daytime and nighttime settings,
    -and those times can be changed as described below.
    +Allsky also has a <strong>daytime</strong> and <strong>nighttime</strong>,
    +but they determine when to use your daytime and nighttime settings,
    +and can be changed as described below.
    +<blockquote>
    +Don't confuse Allsky's <strong>daytime</strong> and <strong>nighttime</strong>
    +with your actual daytime and nighttime.
    +Allsky's version should really be called <strong>daytime settings start</strong> and
    +<strong>nighttime settings start</strong>.
    +</blockquote>
     </p>
     <p>
    -Allsky starts using your <strong>daytime</strong> settings
    -at your actual sunrise time <strong>plus</strong> an offset you define,
    -called the <span class="WebUISetting">Angle</span>.
    +Allsky's <strong>daytime</strong> is your actual sunrise time <strong>plus</strong> an offset you define,
    +called the <a external="true" allsky="true" href="/documentation/settings/allsky.html#angle">
    +	<span class="WebUISetting">Angle</span></a>.
     This is set in the <span class="WebUILink">Allsky Settings</span> page of the WebUI
     and is a positive or negative number that indicates the number of degrees
     <em>above</em> (positive) or <em>below</em> (negative) the horizon the Sun is when daytime starts.
    -We refer to <em>Allsky's daytime</em> as the period when Allsky is using your daytime settings.
     </p>
     <p>
    -Allsky starts using your <strong>nighttime</strong> settings
    -at your actual sunset time <strong>minus</strong> the <span class="WebUISetting">Angle</span>.
    -We refer to <em>Allsky's nighttime</em> as the period when Allsky is using your nighttime settings.
    +Allsky's <strong>nighttime</strong> is your actual sunset time <strong>minus</strong>
    +the <span class="WebUISetting">Angle</span>.
     </p>
     <p>
     As an example, assume the Sun rises at 7:00 AM and sets at 18:00 (6:00 PM).
    @@ -56,14 +60,14 @@ <h2>What does the <span class="WebUISetting">Angle</span> setting do?
     and that's when Allsky will start using your daytime and nightttime settings, respectively.
     </p>
     
    -<blockquote>
    +<p>
     The Sun moves at 15 degrees per hour, so 6 degrees
     is 24 minutes (6 degrees / 15 degrees * 60 minutes).
     <br>Remember: daytime ADDs the number of minutes represented by
     <span class="WebUISetting">Angle</span> and nighttime SUBTRACTs it.
     <br>Subtracting a <em>negative</em> number is the same as adding a <em>positive</em> number,
     so 100 MINUS -5 is the same as 100 PLUS 5.
    -</blockquote>
    +</p>
     
     <p>
     Using an <span class="WebUISetting">Angle</span> of <span class="WebUIValue">6</span> degrees,
    @@ -80,7 +84,7 @@ <h4>Why is Angle important?</h4>
     <p>
     You tell Allsky when to start using your daytime settings and nighttime settings
     by adjusting your <span class="WebUISetting">Angle</span>.
    -</p>
    +<!--
     <p>
     Astrophotographers rarely start taking pictures at sunset since it's still too light.
     Instead, they start taking pictures when it's much darker, often one of the following times:
    @@ -97,9 +101,8 @@ <h4>Why is Angle important?</h4>
     	This is 1 hour, 12 minutes after sunset through 1 hour, 12 minutes before sunrise.
     </ul>
     </p>
    -<p>
    -Many people use an <span class="WebUISetting">Angle</span>
    -of <span class="WebUIValue">-6</span> for their allsky camera but it's
    +-->
    +Many people use <span class="WebUIValue">-6</span> but it's
     up to you to determine where the daytime and nighttime transition points should be.
     <blockquote>
     Startrails, keograms, and timelapse videos are created at the end of nighttime,
    @@ -111,51 +114,30 @@ <h4>Why is Angle important?</h4>
     
     <h3>Trying different values</h3>
     <p>
    -To see today's daytime and nighttime based on your <span class="WebUISetting">Angle</span>,
    -execute the following.
    -It will show you the values for an
    -angle of 0 degrees as well as for your <span class="WebUISetting">Angle</span>:
    -<pre>
    -(source ~/allsky/scripts/functions.sh; get_sunrise_sunset)
    -</pre>
    +To see what Allsky uses for today's daytime and nighttime based on your
    +<span class="WebUISetting">Angle</span>,
    +<span class="WebUISetting">Latitude</span>, and
    +<span class="WebUISetting">Longitude</span>, execute:
    +<pre>allsky-config  show_start_times</pre>
     </p>
     <p>
    -If you specify a number to <code>get_sunrise_sunset</code> it will use that as an angle
    -instead of your <span class="WebUISetting">Angle</span>.
    -If you want daytime and nighttime to start at a certain time,
    -call <code>get_sunrise_sunset</code> with different angles until you get the times you want:
    -<pre>
    -source ~/allsky/scripts/functions.sh
    -get_sunrise_sunset -7.2
    -get_sunrise_sunset -7.5
    -</pre>
    -</p>
    -
    -<h3>Advanced <code>get_sunrise_sunset</code> usage</h3>
    +You can tell it to use different values by appending one or more of the following
    +to the command above:
     <p>
    -<code>get_sunrise_sunset</code> takes up to three arguments, in this order:
    -<ol class="minimalPadding">
    -	<li><strong>angle</strong>: if <code>""</code> (an empty string) it will use
    -		your <span class="WebUISetting">Angle</span>.
    -	<li><strong>latitude</strong>: if <code>""</code> it will use
    -		your <span class="WebUISetting">Latitude</span>.
    -	<li><strong>longitude</strong>: if <code>""</code> it will use
    -		your <span class="WebUISetting">Longitude</span>.
    -</ol>
    -If you skip an argument and specify one after it,
    -you must replace the skipped argument(s) with <code>""</code>:
    +&nbsp; &nbsp; <code>--angle <b>some_angle</b></code>
    +<br>
    +&nbsp; &nbsp; <code>--latitude <b>some_latitude</b></code>
    +<br>
    +&nbsp; &nbsp; <code>--longitude <b>some_longitude</b></code>
     </p>
    +
    +For example:
     <pre>
    -source ~/allsky/scripts/functions.sh
    -get_sunrise_sunset "" 31.1             <span class="shComment"># latitude only</span>
    -get_sunrise_sunset "" "" 88.2W         <span class="shComment"># longitude only</span>
    +allsky-config  show_start_times  --angle -7.1  --longitude 105W
     </pre>
    -This can be useful for debugging purposes to determine what values to use
    -to cause a day/night transition in a few minutes,
    -rather than waiting for tomorrow morning.
    -for debugging purposes
    -
    -<br><p></p>
    +If you want daytime and nighttime to start at a certain time,
    +execute the command with different angles until you get the times you want.
    +</p>
     
     
     </div><!-- Layout-main -->
    diff --git a/html/documentation/explanations/constellationOverlay.html b/html/documentation/explanations/constellationOverlay.html
    index 83e8fb4ea..80a0ca5e1 100644
    --- a/html/documentation/explanations/constellationOverlay.html
    +++ b/html/documentation/explanations/constellationOverlay.html
    @@ -34,7 +34,7 @@
     <blockquote>
     By default the constellation overlay icon doesn't appear since many people never align
     their overlay with the stars in their image,
    -thereby making their overlay useless and misleading to others who might click on it.
    +thereby making their overlay useless and misleading to others who might view it.
     <br>
     You should only make the icon appear if you are going to follow the instructions
     below to align the overlay.
    @@ -54,8 +54,8 @@
     	<li>Refresh your browser.
     </ul>
     <p>
    -For more information on this, and other Website settings,
    -visit the <a allsky="true" href="/documentation/settings/Website.html">Allsky Website Settings</a>
    +For more information on this, and other Website settings, visit the
    +<a allsky="true" href="/documentation/settings/allskyWebsite.html">Allsky Website Settings</a>
     page.
     </p>
     
    @@ -71,9 +71,8 @@ <h2>Adjusting image size</h2>
     		<span class="fileName">configuration.json</span> file.
     		The image's width will be automatically determined.
     	<li>Refresh your browser.
    -	<li>If you set
    -		<span class="editorShell">RESIZE_UPLOADS</span><span class="editorSpecial">=</span><span class="editorString">"true"</span>
    -		in <span class="fileName">config.sh</span> that size should be the same or greater than the size of the
    +	<li>If you resized the image via the WebUI
    +		the final images size should be the same or greater than the size of the
     		<span class="editorSetting">imageHeight</span> value.
     </ul>
     
    diff --git a/html/documentation/explanations/darkFrames.html b/html/documentation/explanations/darkFrames.html
    index de225827f..4352fdb12 100644
    --- a/html/documentation/explanations/darkFrames.html
    +++ b/html/documentation/explanations/darkFrames.html
    @@ -27,7 +27,7 @@
     <p>
     All cameras produce noise which can be one or more of the following:
     </p>
    -<ul>
    +<ul class="minimalPadding">
     	<li>A salt and pepper-like pattern.
     	<li>Pixels that are always on and appear white on a mono camera,
     		or red, green, or blue on a color camera.
    @@ -42,18 +42,18 @@
     </p>
     <blockquote>
     Even expensive cameras like the Hubble Space Telescope produce noise,
    -which is why their electronics are cooled to extremely cold temperatures.
    +which is why they are cooled to extremely cold temperatures.
     Most cameras designed for amature astrophotographers are also cooled,
     but typically only 10 - 30 degrees C below ambient temperature.
     </blockquote>
     <p>
     Many people find noise distracting, and a good way to decrease it is to subtract
     it from a captured image.
    -A picture that contains your desired object and noise is called a "light frame".
    +A picture that contains your desired object, e.g., night sky, and noise is called a "light frame".
     A picture with just the noise is called a "dark frame" because it's mostly dark.
     Subtracting a dark frame from a light frame leaves just your desired object.
     </p>
    -<br><blockquote>
    +<blockquote>
     Dark frames don't currently work with RPi HQ cameras,
     but they will in a future release of Allsky when running on the Bullseye or newer operating system.
     </blockquote>
    @@ -80,7 +80,7 @@ <h2>When should I take dark frames?</h2>
     You'll probably want to take dark frames before you install your allsky camera,
     especially if the camera will be difficult to access, for example, on the top of a tall pole.
     The noise produced by a camera changes over time so you'll want to take new darks
    -whenever you notice a "too much" noise.
    +whenever you notice "too much" noise.
     </p>
     <p>
     Some people put their camera in the freezer before taking darks,
    @@ -95,42 +95,41 @@ <h2>When should I take dark frames?</h2>
     
     <h2>How do I take and use darks?</h2>
     <details><summary></summary>
    -<p>
    -There are two steps that you'll want to execute one after the other with no time inbetween.
    +<p id="howtousedarks">
    +Execute these steps to take and then use dark frames.
     </p>
    +<h4>Capture dark frames</h4>
     <ol>
    -<li>Capture dark frames:
    -	<ul>
    -	<li>Cover your camera lens and/or dome.
    -		Make sure NO light can get in.
    -	<li>In the WebUI open the <b>Camera Settings</b> page and set
    +	<li>In the WebUI open the <span class="WebUILink">Allsky Settings</span> page and set
     		<span class="WebUISetting">Take Dark Frames</span> to <b>Yes</b>.
     	<li>Click on the
     		<span class="btn btn-primary btn-not-real btn-small">Save changes</span> button.
    -		This restarts Allsky with the new settings.
    -	<li>Dark frames are created in the <span class="fileName">~/allsky/darks</span> directory.
    +		This will stop Allsky and wait until you manually start it below.
    +	<li>Cover your camera lens and/or dome.
    +		Make sure NO light can get in.
    +	<li>Start Allsky: <code>systemctl start allsky</code>.
    +		Dark frames will be saved in the <span class="fileName">~/allsky/darks</span> directory.
     		A new dark is created every time the sensor temperature changes by 1 degree C.
    -	<li>If there are no dark frames in the <span class="fileName">~/allsky/darks</span>
    +		<br>
    +		If there are no dark frames in the <span class="fileName">~/allsky/darks</span>
     		folder something went wrong so check the
     		<span class="fileName">/var/log/allsky.log</span> file.
    -	<li>After you are done (possibly in the morning), stop Allsky:
    -		<code>systemctl stop allsky</code>.
    +	<li>After you are done taking darks,
    +		set <span class="WebUISetting">Take Dark Frames</span> in the WebUI to <b>No</b>.
    +		Allsky will stop and wait for you to manually start it.
     	<li>Remove the cover from the lens/dome.
    -	</ul>
    +	<li>Restart Allsky: <code>systemctl start allsky</code>.
    +</ol>
     
    -<li>Subtract dark frames:
    -	<ul>
    -	<li>On the <b>Camera Settings</b> page in the WebUI set:
    -		<ul>
    -		<li><span class="WebUISetting">Take Dark Frames</span> to <b>No</b>
    -		<li><span class="WebUISetting">Use Dark Frames</span> to <b>Yes</b>
    -		</ul>
    +<h4>Subtract dark frames</h4>
    +<ol>
    +	<li>On the <span class="WebUILink">Allsky Settings</span> page in the WebUI set
    +		<span class="WebUISetting">Use Dark Frames</span> to <b>Yes</b>.
     	<li>Click on the
     		<span class="btn btn-primary btn-not-real btn-small">Save changes</span> button.
     		This will restart Allsky taking light frames, and subtract dark frames at night.
    -	<li>You will get an error message if there aren't any dark frames.
    -	</ul>
     </ol>
    +You will get an error message in the WebUI if there aren't any dark frames.
     </details>
     
     <h2>How does the software handle darks?</h2>
    @@ -138,12 +137,15 @@ <h2>How does the software handle darks?</h2>
     <p>
     When you are <b>taking</b> dark frames, the software will turn off auto-exposure,
     auto-gain, and any overlay settings (e.g., time)
    -and use your nighttime settings for Max Auto-Exposure, Gain, and Binning.
    +and use your nighttime settings for
    +<span class="WebUISetting">Max Auto-Exposure</span>,
    +<span class="WebUISetting">Gain</span>,
    +and
    +<span class="WebUISetting">Binning</span>.
     </p>
     <p>
     <blockquote>
    -If you later change the Max Auto-Exposure, Gain,
    -or Binning you should discard the old darks and take new ones.
    +If you later change any of those settings you should discard the old darks and take new ones.
     If you think you'll go back to the original settings,
     save the old darks instead of discarding them.
     </blockquote>
    @@ -176,4 +178,3 @@ <h2>How does the software handle darks?</h2>
     </body>
     </html>
     <script> includeHTML(); </script>
    -
    diff --git a/html/documentation/explanations/exposureGainSaturation.html b/html/documentation/explanations/exposureGainSaturation.html
    index f82ba5c94..e46a932d8 100644
    --- a/html/documentation/explanations/exposureGainSaturation.html
    +++ b/html/documentation/explanations/exposureGainSaturation.html
    @@ -66,10 +66,20 @@ <h2>Image Color</h2>
     the amount of green in an image by adjusting the amount of red and blue).
     </p>
     <p>
    -We suggest trying <span class="WebUISetting">Auto White Balance</span>
    -and if you don't like the results, turn it off and set the red and blue balance manually.
    +We suggest trying <span class="WebUISetting">Auto White Balance</span> during the day
    +and if you don't like the results,
    +turn it off and set the red and blue balance manually.
     You can change the <span class="WebUISetting">Auto White Balance</span> and red and blue balances
     separately for daytime and nighttime.
    +<blockquote class="warning">
    +You may not want to disable
    +<span class="WebUISetting">Auto White Balance</span> at night
    +since it may significantly increase the total time needed to get one picture.
    +Some camera's auto white balance algorithm works by taking several pictures in a row,
    +and if each picture is 60 seconds you may wait 3 minutes for each picture.
    +If in doubt, enable <span class="WebUISetting">Auto White Balance</span> at night
    +and see what happens.
    +</blockquote>
     </p>
     
     
    @@ -87,26 +97,22 @@ <h2>Saturation (RPi cameras only)</h2>
     <h2 id="stretch">Stretch</h2>
     <p>
     Stretching an image changes its contrast (difference between light and dark)
    -and is good to bring out details in nighttime pictures.
    -Stretching is enable by editing the <span class="fileName">config.sh</span> file via the WebUI.
    -The settings are:
    +and is good to bring out details in pictures.
    +There are separate stretch settings for daytime and nighttime that work the same
    +(daytime stretching is not very common).
    +The stretch-related settings are:
     <ul>
    -<li><span class="editorSetting">AUTO_STRETCH</span>
    -	(default: <span class="editorBool">false</span>)
    -	<br>Set to <span class="editorBool">True</span> to enable stretching.
    -<li><span class="editorSetting">AUTO_STRETCH_AMOUNT</span>
    -	(default: <span class="editorNum">10</span>)
    -	<br>The amount to lighten an image,
    -	<span class="editorNum">0</span> is none,
    -	<span class="editorNum">3</span> is typical,
    -	and <span class="editorNum">20</span> is a lot.
    +<li><span class="WebUISetting">Stretch Amount</span>
    +	(default: <span class="WebUIValue">0</span> which disables stretching)
    +	<br><span class="WebUIValue">3</span> is typical
    +	and <span class="WebUIValue">20</span> is a lot.
     	Higher numbers lighten the image more.
    -<li><span class="editorSetting">AUTO_STRETCH_MID_POINT</span>
    -	(default: <span class="editorString">10%</span>)
    -	<br>This specifies what parts of the image should be lightened:
    -	<span class="editorString">0%</span> lightens black items,
    -	<span class="editorString">50%</span> lightens middle-gray items, and
    -	<span class="editorString">100%</span> lightens white items.
    +<li><span class="WebUISetting">Stretch mid point</span>
    +	(default: <span class="WebUIValue">10</span>)
    +	<br>This specifies what part of the image should be lightened:
    +	<span class="WebUIValue">0</span> lightens black items,
    +	<span class="WebUIValue">50</span> lightens middle-gray items, and
    +	<span class="WebUIValue">100</span> lightens white items.
     </ul>
     You may find that you can decrease the gain, and hence the noise,
     by increasing the stretch.
    @@ -116,31 +122,35 @@ <h2 id="stretch">Stretch</h2>
     <h3>Sample Stretch Images</h3>
     
     <p>
    -<span class="editorSetting">AUTO_STRETCH</span>="<span class="editorBool">False</span>"
    +<span class="WebUISetting">Stretch Amount</span>: <span class="WebUIValue">0</span>
    +<br>The exposure is 60 seconds; anything longer will produce streaks with
    +most lenses.
    +The gain is 250 which is near the maximum for the camera,
    +and is already producing some noise.
     <br><img class="imgCenter" loading="lazy" src="Stretch_0.png" width="50%">
     </p>
     
     <br><p>
    -<span class="editorSetting">AUTO_STRETCH_AMOUNT</span>=<span class="editorNum">10</span>,
    -<span class="editorSetting">AUTO_STRETCH_MID_POINT</span>=<span class="editorString">10%</span>
    +<span class="WebUISetting">Stretch Amount</span>: <span class="WebUIValue">10</span>,
    +<span class="WebUISetting">Stretch mid point</span>: <span class="WebUIValue">10</span>
     <br>Notice the dark parts of the image are much brighter,
     and the light stars are only a little brighter
     (but the dark stars are much brighter and easier to see).
     <br><img class="imgCenter" loading="lazy" src="Stretch_10x10.png" width="50%">
     
     <br><p>
    -<span class="editorSetting">AUTO_STRETCH_AMOUNT</span>=<span class="editorNum">10</span>,
    -<span class="editorSetting">AUTO_STRETCH_MID_POINT</span>=<span class="editorString">30%</span>
    +<span class="WebUISetting">Stretch Amount</span>: <span class="WebUIValue">10</span>,
    +<span class="WebUISetting">Stretch mid point</span>: <span class="WebUIValue">30</span>
     <br>Notice a very slight overall increase in brightness,
     but a noticable increase in brightness of the stars.
     <br><img class="imgCenter" loading="lazy" src="Stretch_10x30.png" width="50%">
     </p>
     
     <br><p>
    -<span class="editorSetting">AUTO_STRETCH_AMOUNT</span>=<span class="editorNum">20</span>,
    -<span class="editorSetting">AUTO_STRETCH_MID_POINT</span>=<span class="editorString">10%</span>
    +<span class="WebUISetting">Stretch Amount</span>: <span class="WebUIValue">20</span>,
    +<span class="WebUISetting">Stretch mid point</span>: <span class="WebUIValue">10</span>
     <br>Compare this image to the
    -<span class="editorSetting">AUTO_STRETCH_AMOUNT</span>=<span class="editorNum">10</span>
    +<span class="WebUISetting">Stretch Amount</span>: <span class="WebUIValue">10</span>
     image;
     this one is even brighter.
     <br><img class="imgCenter" loading="lazy" src="Stretch_20x10.png" width="50%">
    diff --git a/html/documentation/explanations/fileExplorer.png b/html/documentation/explanations/fileExplorer.png
    index 30aa6ae9c..19913f31e 100644
    Binary files a/html/documentation/explanations/fileExplorer.png and b/html/documentation/explanations/fileExplorer.png differ
    diff --git a/html/documentation/explanations/imageSDcard.html b/html/documentation/explanations/imageSDcard.html
    new file mode 100644
    index 000000000..42a7a35ba
    --- /dev/null
    +++ b/html/documentation/explanations/imageSDcard.html
    @@ -0,0 +1,144 @@
    +<!DOCTYPE html>
    +<html lang="en">
    +<head>
    +	<meta charset="utf-8">
    +	<meta http-equiv="X-UA-Compatible" content="IE=edge">
    +	<meta name="viewport" content="width=device-width, initial-scale=1">
    +	<script src="../js/documentation.js" type="application/javascript"></script>
    +	<link href="../css/light.css" rel="stylesheet">
    +	<link href="../bower_components/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
    +	<script src="../bower_components/jquery/dist/jquery.min.js"></script>
    +	<script src="../bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
    +	<style>
    +		#pageTitle::before {
    +			content: "Imaging an SD card";
    +		} 
    +	</style>
    +	<link href="../css/documentation.css" rel="stylesheet">
    +	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
    +	<title>Imaging an SD Card</title>
    +</head>
    +<body>
    +<div w3-include-html="/documentation/pageHeader.html" id="pageHeader"></div>
    +<div class="Layout">
    +<div class="Layout-sidebar" w3-include-html="/documentation/sidebar.html" id="sidebar"></div>
    +<div class="Layout-main markdown-body" id="mainContents">
    +
    +<p>
    +This page describes how to image an SD card for use by Allsky.
    +This needs to be done prior to installing Allsky the first time,
    +but can also be done anytime you want to "start over" or upgrade
    +the Raspberry Pi Operating System (OS).
    +</p>
    +
    +<p>
    +There are two main scenarios when installing Pi OS.
    +</p>
    +<h2>1. Re-image an SD card but keep some files</h2>
    +<p>
    +In this scenario, you have an SD card that you've been using and
    +want to upgrade the Pi OS but keep some files,
    +such as the Allsky images, darks, and configuration files.
    +This scenario can also be used if you want to start over with a clean install
    +but keep your Allsky files.
    +</p>
    +<p>
    +If you have <strong>two SD cards</strong>, the process is straightforward:
    +<ol class="minimalPadding">
    +	<li>Image the second SD card using the
    +		<a href="#install">instructions below.</a>
    +	<li>Plug the newly-imaged SD card into your Pi and turn it on.
    +	<li>Using an SD card-to-USB adapter, plug it into a USB port on your Pi
    +		and copy whatever files you want from it to the main SD card.
    +		<blockquote>
    +		Copy the <span class="fileName">~/allsky</span> directory on the saved SD card
    +		to <span class="fileName">~/allsky-OLD</span> on the main SD card.
    +		</blockquote>
    +</ol>
    +</p>
    +<p>
    +If you only have <strong>one SD card</strong>, consider getting another one
    +and use the instructions above.
    +It's always good to have a spare SD card anyway in case the first one goes bad.
    +<br>If you can't obtain a second card, do the following:
    +<ol class="minimalPadding">
    +	<li>Copy <span class="fileName">~/allsky</span> plus any other
    +		files, images, etc. you want to keep from your SD card to a
    +		USB drive, PC, Mac, or another device.
    +	<li>Image the SD card using the
    +		<a href="#install">instructions below.</a>
    +	<li>Plug the newly-imaged SD card into your Pi and turn it on.
    +	<li>Restore your files to the SD card.
    +		<blockquote>
    +		Copy the saved <span class="fileName">~/allsky</span> directory to
    +		<span class="fileName">~/allsky-OLD</span> on the new SD card.
    +		</blockquote>
    +	<li><a href="../installations/allsky.html" external="true">Install Allsky</a>
    +		and tell the installation you want to use the prior version of Allsky;
    +		it will then restore your saved images, darks, and configuration files.
    +</ol>
    +</p>
    +
    +<h2 id="install">2. First install or "starting over"</h2>
    +<p>
    +In this scenario, you either have a new SD card or an existing one that you
    +plan to wipe clean and "start over".
    +</p>
    +
    +<p>
    +<ol>
    +	<li>Download the 
    +		<a href="https://www.raspberrypi.com/software/" external="true">Raspberry Pi Imager></a>
    +		to a PC or MAC.
    +	<li>Start the Imager and choose your "Raspberry Pi Device".
    +	<li>Choose your "Storage" (where to write the operating system to - your SD card).
    +		If nothing appears in the list then the Imager can't find your SD card.
    +	<li>Choose the Pi "Operating System".
    +		<blockquote>
    +		Select the <strong>(Recommended)</strong> entry which is usually the first one.
    +		For Pi 4 and 5 models, that will be <strong>Raspberry Pi OS (64-bit)</strong>.
    +		For older models it will be <strong>Raspberry Pi OS (Legacy, 32-bit)</strong>.
    +		<br>Allsky installs many of the packages used by the "Desktop" version
    +		so we suggest installing it.
    +		</blockquote>
    +		<br><img src="PiImager.png" title="Chosing Pi OS" width="50%">
    +	<li>Press "NEXT".
    +	<li>When asked to <strong>apply OS Customisation settings</strong> press
    +		<strong>EDIT SETTINGS</strong> and make these changes:
    +		<blockquote class="warning">
    +		Skipping this step means you'll need to make those changes after
    +		turning your Pi on the first time.
    +		Wi-Fi won't work so you'll need to use a wired connection to the Pi,
    +		or connect a monitor, keyboard, and mouse to it.
    +		<p><strong>Do not skip this step unless you enjoy pain!</strong></p>
    +		</blockquote>
    +	<ul>
    +		<li>GENERAL tab:
    +		<ul>
    +			<li><u>Set hostname</u>: <code>allsky</code>
    +				<br>unless you have multiple Pi's on your network in which case
    +				they must all have unique names.
    +			<li><u>Set username and password</u>: <code>pi</code> and whatever you want
    +				for the password.
    +			<li><u>Configure wireless LAN</u>:
    +				<br>it's MUCH easier to do this now rather than after your Pi is running.
    +			<li><u>Set local settings</u>:
    +				<br>This doesn't actually change the "Locale"; it just changes
    +				the time zone and keyboard layout.
    +				Allsky will prompt for the <span class="WebUISetting">Locale</span>
    +				during installation.
    +		</ul>
    +		<li>SERVICES tab:
    +		<ul>
    +			<li>Enable SSH - Use password authentication
    +		</ul>
    +	</ul>
    +<ol>
    +
    +</div><!-- Layout-main -->
    +</div><!-- Layout -->
    +</body>
    +</html>
    +<script> includeHTML(); </script>
    +
    diff --git a/html/documentation/explanations/keograms.html b/html/documentation/explanations/keograms.html
    index 137aef81c..105b12cb4 100644
    --- a/html/documentation/explanations/keograms.html
    +++ b/html/documentation/explanations/keograms.html
    @@ -15,7 +15,9 @@
     		} 
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
    +	<link href="../css/custom.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
     	<title>Keograms</title>
     </head>
     <body>
    @@ -25,53 +27,84 @@
     <div class="Layout-main markdown-body" id="mainContents">
     
     <p>
    -A <b>Keogram</b> is an image giving a quick view of a night's activity.
    -For each nighttime image a central vertical column 1 pixel wide is extracted.
    -All these columns are then stitched together from left to right.
    -This results in a timeline that reads from dusk to dawn.
    +A <b>keogram</b> is an image that gives a quick view of a day's activity.
    +A central vertical column 1 pixel wide is extracted from each image
    +and added to the keogram from left to right.
    +<blockquote>
    +Only images that are saved are included in a keogram,
    +so if you don't save your daytime and/or nightime images,
    +they won't be included.
    +</blockquote>
     </p>
    -<img allsky="true" src="keogram.png" title="Sample Keogram" class="imgCenter imgBorder" loading="lazy">
    +<img allsky="true" src="keogram.png" title="Sample Keogram"
    +	class="imgCenter imgBorder" loading="lazy">
     <p>
    -To get the best results north should be at the top of the image.
    -If it's not, include <code>--rotate DEG</code> in the
    -<span class="shSetting">KEOGRAM_EXTRA_PARAMETERS</span> setting in
    -<span class="fileName">config.sh</span>,
    -where "DEG" is the degrees of rotation.
    +To get the best results, North should be at the top of the image.
    +If it's not, include <span class="WebUIValue">--rotate DEG</span> in the
    +<span class="WebUISetting">Keogram Extra Parameters</span> setting in the WebUI,
    +where <span class="WebUIValue">DEG</span> is the degrees of rotation.
     That way, using a fisheye lens, you end up with the bottom of the keogram
     being the southern horizon and the top being the northern horizon.
     </p>
     
     <blockquote>
    -The keogram will only show what happens at the meridian during the night
    -and will not display events on the east or west.
    +Keograms only show what happens at the center of the image
    +and will not display events on the East or West.
     </blockquote>
     
     
    -<h2><code>keogram</code> program</h2>
    -<details><summary></summary>
    +<h2>Creating a keogram</h2>
     <p>
    -The <code>keogram</code> program takes multiple arguments:
    +You specify whether or not you want a keogram automatically created via the
    +<a allsky="true" external="true" href="/documentation/settings/allsky.html#keograms">
    +	<span class="WebUISetting">Generate</span></a>
    +setting in the <span class="subSettingsHeader">Keograms</span> sub-section of the WebUI.
    +When enabled, a keogram will be created at the end of night and will contain information
    +for the prior 24 hours.
     </p>
    +<p>
    +The easiest way to create and optionally upload a keogram is via the
    +<code>generateForDay.sh</code> command.
    +For example, to create and then upload a keogram to any Allsky Website and/or remote server 
    +you have for July 10, 2024:
     <pre>
    -Usage:  keogram -d &lt;imagedir&gt; -e &lt;ext&gt; -o &lt;outputfile&gt; [KEOGRAM_EXTRA_PARAMETERS]
    +generateForDay.sh --keogram 20240710
    +generateForDay.sh --upload --keogram 20240710
    +</pre>
    +This will use the settings specified in the
    +<span class="subSettingsHeader">Keograms</span> sub-section of the WebUI.
    +</p>
    +<p>
    +<code>generateForDay.sh</code> calls the <code>keogram</code> program
    +to actually create the keogram, passing it several arguments
    +(the <u>underlined</u> ones below),
    +plus any others you add to the 
    +<span class="WebUISetting">Keogram Extra Parameters</span> setting.
    +</p>
    +<p>
    +You can execute the <code>keogram</code> program manually,
    +but will need to specify at least all the required arguments.
    +</p>
    +<pre>
    +Usage:  keogram -d &lt;imagedir&gt; -e &lt;ext&gt; -o &lt;outputfile&gt; [optional arguments]
     
     Arguments:
    --d | --directory &lt;str&gt; : directory from which to load images (required)
    --e | --extension &lt;str&gt; : image extension to process (required)
    --o | --output-file &lt;str&gt; : name of output file (required)
    +<u>-d | --directory &lt;str&gt; : directory from which to load images (required)</u>
    +<u>-e | --extension &lt;str&gt; : image extension to process (required)</u>
    +<u>-o | --output-file &lt;str&gt; : name of output file (required)</u>
     -r | --rotate &lt;float&gt; : number of degrees to rotate image, counterclockwise (0)
     -s | --image-size &lt;int&gt;x&lt;int&gt; : only process images of a given size, eg. 1280x960
     -h | --help : display this help message
     -v | --verbose : Increase logging verbosity
     -n | --no-label : Disable hour labels
    --C | --font-color &lt;str&gt; : label font color, in HTML format (0000ff)
    --L | --font-line &lt;int&gt; : font line thickness (3)
    --N | --font-name &lt;str&gt; : font name (simplex)
    --S | --font-size &lt;float&gt; : font size (2.0)
    +<u>-C | --font-color &lt;str&gt; : label font color, in HTML format (0000ff)</u>
    +<u>-L | --font-line &lt;int&gt; : font line thickness (3)</u>
    +<u>-N | --font-name &lt;str&gt; : font name (simplex)</u>
    +<u>-S | --font-size &lt;float&gt; : font size (2.0)</u>
     -T | --font-type &lt;int&gt; : font line type (1)
     -Q | --max-threads &lt;int&gt; : limit maximum number of processing threads. (use all cpus)
     -q | --nice-level &lt;int&gt; : nice(2) level of processing threads (10)
    --x | --image-expand : expand image to get the proportions of source - avoids tall and narrow images
    +<u>-x | --image-expand : expand image to get the proportions of source - avoids tall and narrow images</u>
     -c | --channel-info : show channel infos - mean value of R/G/B
     -f | --fixed-channel-number &lt;int&gt; : define number of channels 0=auto, 1=mono, 3=rgb (0=auto)
     
    @@ -81,27 +114,48 @@ <h2><code>keogram</code> program</h2>
     </pre>
     
     <br><p>
    -Example when running the program manually:
    +Example of running the <code>keogram</code> program manually:
     </p>
     <pre>
    -cd ~/allsky
    -./keogram -d images/20220710/ -e jpg -o images/20220710/keogram/keogram.jpg --rotate 42 --font-size 2
    +cd ~/allsky/bin
    +./keogram -d images/20240710 -e jpg -o images/20240710/keogram/keogram.jpg --rotate 42 --font-size 2
     </pre>
     
    -<br><p>
    -To disable keograms, open <span class="fileName">config.sh</span> in the WebUI's <b>Editor</b> page
    -and set <span class="shSetting">KEOGRAM</span> to "false".
    -</p>
    -
    +<!-- THESE SETTINGS NO LONGER EXIST
     <blockquote>
     If you set the
     <span class="WebUISetting">Image Width</span> and
     <span class="WebUISetting">Image Height</span>
    -of your camera in the WebUI <b>Camera Settings</b> page to the actual values of your camera,
    -keogram generation will skip any file that's not the correct size.
    -This will eliminate any garbage images that happen to be generated.
    +of your camera in the WebUI to the actual values of your camera,
    +keogram generation will skip any file that's not that size.
    +This eliminates any garbage images that happen to be generated.
     </blockquote>
    +-->
     
    +<h2>Troubleshooting</h2>
    +<p>
    +If a keogram isn't being created, make sure the
    +<span class="WebUISetting">Generate</span> setting is enabled.
    +If that IS enabled, run:
    +<pre>generateForDay.sh --keogram DATE</pre>
    +and check for errors.
    +<blockquote>
    +It is extremely rare that a keogram isn't created.
    +</blockquote>
    +</p>
    +<p>
    +If a keogram isn't being uploaded, make sure the
    +<span class="WebUISetting">Upload</span> setting is enabled.
    +If that IS enabled, run:
    +<pre>generateForDay.sh --upload --debug --keogram DATE</pre>
    +and check for errors.
    +If needed, run <code>testUpload.sh</code> to see why the upload fails.
    +</p>
    +<p>
    +If your keograms are tall and skinny you can have them created so they
    +look like regular images by enabling the
    +<span class="WebUISetting">Expand</span> setting.
    +</p>
     
     </div><!-- Layout-main -->
     </div><!-- Layout -->
    diff --git a/html/documentation/explanations/layout.html b/html/documentation/explanations/layout.html
    index 3cd69bcca..1077e3977 100644
    --- a/html/documentation/explanations/layout.html
    +++ b/html/documentation/explanations/layout.html
    @@ -40,8 +40,10 @@
     </p>
     <p>
     Notice the colors?
    -The <code>ls</code> (<u>l</u>ist file<u>s</u>) command displays executable programs in green,
    -directories in blue, and "regular" files in black.
    +The <code>ls</code> (<u>l</u>ist file<u>s</u>)
    +command displays executable programs in <span class="lsGreen">green</span>,
    +directories in <span class="lsBlue">blue</span>,
    +and "regular" files in <span class="lsBlack">black</span>.
     </p>
     <p>
     Here's what's in the directory:
    @@ -49,7 +51,7 @@
     	<li><span class="lsGreen">allsky.sh</span>
     		<br>
     		This is the program that's executed when you start Allsky.
    -		It calls the appropriate program to take the picture (see the next bullet).
    +		It calls the appropriate program to take pictures.
     
     	<li><span class="lsBlue">assets</span>
     		<br>
    @@ -68,23 +70,13 @@
     	<li><span class="lsBlue">config</span>
     		<br>
     		Contains all the configuration files.
    -		<blockquote class="warning">
    -		Unlike past releases, you should <strong>not</strong> manually edit the files
    -		in this directory.
    -		</blockquote>
     		<u>Some</u> of the files in this directory include:
     		<ul>
     			<li><span class="fileName">settings.json</span> is updated via the
     				<span class="WebUIWebPage">Allsky Settings</span> page in the WebUI
    -				and contains the settings used to take images.
    -			<li><span class="fileName">config.sh</span> and
    -				<span class="fileName">ftp-settings.sh</span>
    -				are editied via the <span class="WebUIWebPage">Editor</span>
    -				page in the WebUI and contain other settings used by Allsky.
    -				<blockquote>
    -				The settings in these files will be moved to the
    -				<span class="fileName">settings.json</span> file in a future release.
    -				</blockquote>
    +				and contains most of the settings used by Allsky.
    +				This file is linked to a file that contains your camera type and camera
    +				model in the file name.
     			<li>The <span class="fileName">modules</span> and
     				<span class="fileName">overlay</span> directories contain the configuration
     				files for the <span class="managerName">Module Manager</span> and
    @@ -95,12 +87,10 @@
     				and determine what settings you see in the WebUI as well as their
     				minimum, maximum, and default values.
     				If you change cameras via the WebUI, these files are re-created.
    -			<li>If you have a remote Allsky Website and configure it via the WebUI
    -				(as you should),
    +			<li>If you have a remote Allsky Website
     				a <span class="fileName">remote_configuration.json</span> file will exist
     				that's uploaded to the remote server whenever you change it via
     				the WebUI's <span class="WebUIWebPage">Editor</span> page.
    -			<li>Advanced users may have a <span class="fileName">uservariables.sh</span> file.
     		</ul>
     
     	<li><span class="lsBlue">config_repo</span>
    @@ -112,14 +102,22 @@
     	<li><span class="lsBlue">darks</span>
     		<br>
     		This holds optional <a href="darkFrames.html">dark frames</a>
    -		which are used to decrease noise in nighttime pictures.
    +		which are used to decrease noise in pictures.
    +
    +	<li><span class="lsBlack">env.json</span>
    +		<br>
    +		This file contains private settings used to upload files
    +		such as user names and passwords.
    +		These settings are in a separate file so you can safely upload the
    +		<span class="fileName">settings.json</span> file to GitHub without exposing
    +		sensitive information.
    +		<br>The data in this file is updated via the WebUI.
     
     	<li><span class="lsBlue">html</span>
     		<br>
    -		All the WebUI files are here, and if you have the Allsky Website installed on your Pi,
    -		its files are in <span class="lsBlue">html</span>/<span class="lsBlue">allsky</span>.
    +		All the WebUI and local Allsky Website files are here.
     		The WebUI itself doesn't have a configuration file,
    -		but the Website has <span class="fileName">configuration.json</span>
    +		but a local Allsky Website has <span class="fileName">configuration.json</span>
     		which is updated via the WebUI's <span class="WebUIWebPage">Editor</span> page.
     		<br>
     		The Allsky Documentation is also in this directory.
    @@ -128,27 +126,24 @@
     		<br>
     		This holds all the daily images, keograms, startrails, and timelapse videos
     		for as long as you have specified in the
    -		<span class="shSetting">DAYS_TO_KEEP</span> setting in
    -		<span class="fileName">config.sh</span>.
    -		<br>
    -		Each day's files are stored in a directory called YYYYMMDD, for example,
    -		<span class="fileName">20230710</span>.
    +		<span class="WebUISetting">Days To Keep</span> setting in the WebUI.
    +		Each day's files are stored in a subdirectory called YYYYMMDD, for example,
    +		<span class="fileName">20240710</span>.
     
     	<li><span class="lsGreen">install.sh</span>
     		<br>
    -		The installation script for Allsky.
    -		Also used to upgrade from a prior release,
    -		and to (re)run certain actions, such as setting the swap space.
    +		The installation / upgrade script for Allsky.
     
     	<li><span class="lsBlack">LICENSE</span>
     		<br>
     		Holds the Allsky license.
    -		People that plan to copy Allsky in part or in whole should be aware of the license.
    +		If you plan to copy Allsky in part or in whole you should be aware of the license.
     
     	<li><span class="lsBlack">Makefile</span>
     		<br>
     		This is used during installation to create directories and binary files
     		and perform other tasks.
    +		You can ignore this file.
     
     	<li><span class="lsBlue">notification_images</span>
     		<br>
    @@ -162,17 +157,20 @@
     		You'll normally view the Allsky Documentation via the WebUI's
     		<span class="WebUIWebPage">Documentation</span> link instead of this file.
     
    +	<li><span class="lsBlack">remoteWebsiteInstall.sh</span>
    +		<br>
    +		The program used to prepare an optional remote Allsky Website.
    +		This should only be run after viewing the
    +		<a allsky="true" href="/documentation/installations/AllskyWebsite.html">
    +			Allsky Website Installation</a>
    +		page for instructions on installing a remote Allsky Website.
    +
     	<li><span class="lsBlue">scripts</span>
     		<br>
     		Holds all the scripts (i.e., programs) used by Allsky while it's running.
     		Several scripts can be manually executed for debugging purposes,
     		per the documentation.
    -		For example running <code>upload.sh</code> manually can help debug upload problems.
    -		<br>
    -		The only script you might ever change is
    -		<span class="fileName">endOfNight_additionalSteps.sh</span>
    -		(and it will be removed in the next release).
    -		See the documentation on how that script is used.
    +		For example running <code>testUpload.sh</code> manually can help debug upload problems.
     
     	<li><span class="lsBlue">src</span>
     		<br>
    @@ -182,16 +180,13 @@
     		<br>
     		Hold temporary files, including most log files, used by Allsky while it's running.
     		The contents of this directory are usually cleaned out after a reboot.
    -		<blockquote>
    -		Other than the <span class="lsBlue">scripts</span> directory described above,
    -		this is the only other directory that you may need to look in,
    -		and that's typically only to troubleshoot a problem.
    -		</blockquote>
     
     	<li><span class="lsGreen">upgrade.sh</span>
     		<br>
    -		A VERY BASIC upgrade script reserved for future use.
    -		<strong>Do not use</strong> - it's for developer testing only.
    +		<strong>Do not use</strong> - it's incomplete and for developer testing only.
    +		<br>
    +		When finished, this script will perform <strong>all</strong>
    +		steps needed to upgrade the existing Allsky to the newest version.
     
     	<li><span class="lsGreen">uninstall.sh</span>
     		<br>
    @@ -209,21 +204,21 @@
     		indicates it's a shell script, it's colored in black which means it is NOT executable.
     		This script is only included in other scripts, never executed on its own.
     
    -	<li><span class="lsBlack">version</span>
    +	<li><span class="lsBlue">venv</span>
     		<br>
    -		A file that holds the version of Allsky you're running.
    -		The installation script (and eventually the WebUI) compare this version to
    -		the one in GitHub to determine if an upgrade is available.
    +		You can ignore this directory.
    +		It holds Python configuration data used by Allsky.
     
    -	<li><span class="lsBlue">website</span>
    +	<li><span class="lsBlack">version</span>
     		<br>
    -		This directory contains the <span class="fileName">install.sh</span>
    -		script used to install the Allsky Website on your Pi or (to help with installing)
    -		on a remote server.
    -		See the
    -		<a allsky="true" href="/documentation/installations/AllskyWebsite.html">Allsky Website Installation</a>
    -		page for instructions on installing the Website.
    +		A file that holds the version of Allsky you're running.
     </ul>
    +
    +<blockquote class="warning">
    +Unless instructed to by an Allsky developer,
    +you should <strong>never</strong> manually edit any Allsky files;
    +instead, use the WebUI.
    +</blockquote>
     </p>
     
     <h2>File Explorer View</h2>
    diff --git a/html/documentation/explanations/requestCameraSupport.html b/html/documentation/explanations/requestCameraSupport.html
    new file mode 100644
    index 000000000..29947052f
    --- /dev/null
    +++ b/html/documentation/explanations/requestCameraSupport.html
    @@ -0,0 +1,126 @@
    +<!DOCTYPE html>
    +<html lang="en">
    +<head>
    +	<meta charset="utf-8">
    +	<meta http-equiv="X-UA-Compatible" content="IE=edge">
    +	<meta name="viewport" content="width=device-width, initial-scale=1">
    +    <script src="../js/documentation.js" type="application/javascript"></script>
    +    <link href="../css/light.css" rel="stylesheet">
    +	<link href="../bower_components/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
    +	<script src="../bower_components/jquery/dist/jquery.min.js"></script>
    +	<script src="../bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
    +	<style>
    +		#pageTitle::before {
    +			content: "Request support for camera"
    +		} 
    +	</style>
    +    <link href="../css/documentation.css" rel="stylesheet">
    +    <link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
    +	<title>Request camera support</title>
    +</head>
    +<body>
    +<div w3-include-html="/documentation/pageHeader.html" id="pageHeader"></div>
    +<div class="Layout">
    +<div class="Layout-sidebar" w3-include-html="/documentation/sidebar.html" id="sidebar"></div>
    +<div class="Layout-main markdown-body" id="mainContents">
    +
    +<p>
    +This page describes how to request that Allsky support a camera.
    +</p>
    +
    +<h1>ZWO Cameras</h1>
    +<p>
    +Open a new
    +<a external="true" href="https://github.com/AllskyTeam/allsky/discussions">Discussion</a>
    +in GitHub using the <strong>New feature requests</strong> category.
    +Let us know what model of ZWO camera it is, and if you know it, when the camera came out.
    +</p>
    +<p>
    +Allsky uses a library provided by ZWO, and that library determines what cameras are supported.
    +ZWO comes out with a new library every few months, so if your camera isn't supported that
    +means the camera is newer than the library Allsky is using.
    +We normally update the library when we release a new Allsky version.
    +</p>
    +
    +<h4>Advanced users</h4>
    +<p>
    +If you can't wait for the next Allsky version you can download the newest ZWO library
    +and install it yourself.
    +Search for "zwo downloads" on the Web and go to the ZWO site.
    +Look for the "Developer" page and download the Linux/Windows/Mac SDK to your Pi.
    +Unzip the file and copy the <span class="fileName">libASICamera2.a</span> files from the
    +library to the various <span class="fileName">~/allsky/src/lib/arm*</span> directories
    +on the Pi,
    +then execute:
    +<pre>
    +cd ~/allsky/src
    +make capture_ZWO
    +sudo systemctl stop allsky
    +cp capture_ZWO ../bin
    +sudo systemctl start allsky
    +</pre>
    +Allsky should now recognize your camera.
    +</p>
    +<h1>RPi and Compatible Cameras</h1>
    +<h3>Step 1: Check if the camera is good for allsky</h3>
    +<p>
    +Connect the camera(s) to the Pi.
    +If you have a single RPi camera connected to your Pi, run:
    +<pre>
    +allsky-config  new_rpi_camera_info
    +</pre>
    +If you have <u>multiple</u> RPi cameras connected,
    +run the following, replacing <code>NUM</code> with the camera number -
    +0 is the first camera and 1 is the second camera.
    +<pre>
    +allsky-config  new_rpi_camera_info --camera NUM
    +</pre>
    +</p>
    +<p>
    +Either way, after a few seconds you'll see something like:
    +<pre>
    +
    +Maximum exposure time for sensor 'imx708_wide_noir' is 220.5 seconds.
    +&gt;&gt;&gt; This will make a good allsky camera. &lt;&lt;&lt;
    +
    +************************
    +When requesting support for this camera, please attach
    +    /home/pi/allsky/tmp/camera_data.txt
    +to your request.
    +************************
    +
    +</pre>
    +</p>
    +<p>
    +<blockquote class="warning">
    +The maximum exposure times of many RPi and compatible cameras are very short,
    +e.g., 15 seconds, so do not make very good allsky cameras.
    +Before requesting that Allsky support a camera
    +(and ideally before you purchase the camera),
    +make sure it'll make a good allsky camera.
    +</blockquote>
    +If the second line in the output is:
    +<pre>
    +&gt;&gt;&gt;This is a short maximum exposure so may not make a good allsky camera.&lt;&lt;&lt;
    +</pre>
    +you may want to consider a different camera.
    +Nighttime exposures are typicall around 60 seconds,
    +so any camera with a shorter maximum may not properly expose nighttime shots.
    +Do NOT request support for the camera since it's likely no one else will use it either.
    +</p>
    +
    +<h3>Step 2: Request support</h3>
    +If the longest exposure the camera supports is enough for you, open a new
    +<a external="true" href="https://github.com/AllskyTeam/allsky/discussions">Discussion</a>
    +in GitHub with a <strong>New feature requests</strong> category.
    +Attach the <span class="fileName">/home/pi/allsky/tmp/camera_data.txt</span>
    +file from above as well as a URL for information on the camera
    +(often a URL of where you bought the camera).
    +</p>
    +
    +</div><!-- Layout-main -->
    +</div><!-- Layout -->
    +</body>
    +</html>
    +<script> includeHTML(); </script>
    diff --git a/html/documentation/explanations/serverLocationToURL.html b/html/documentation/explanations/serverLocationToURL.html
    new file mode 100644
    index 000000000..e12346da4
    --- /dev/null
    +++ b/html/documentation/explanations/serverLocationToURL.html
    @@ -0,0 +1,117 @@
    +<!DOCTYPE html>
    +<html lang="en">
    +<head>
    +	<meta charset="utf-8">
    +	<meta http-equiv="X-UA-Compatible" content="IE=edge">
    +	<meta name="viewport" content="width=device-width, initial-scale=1">
    +	<script src="../js/documentation.js" type="application/javascript"></script>
    +	<link href="../css/light.css" rel="stylesheet">
    +	<link href="../bower_components/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
    +	<script src="../bower_components/jquery/dist/jquery.min.js"></script>
    +	<script src="../bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
    +	<style>
    +		#pageTitle::before {
    +			content: "Mapping Server Locations to URLs";
    +		} 
    +	</style>
    +	<link href="../css/documentation.css" rel="stylesheet">
    +	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
    +	<title>Mapping Server Locations to URLs</title>
    +</head>
    +<body>
    +<div w3-include-html="/documentation/pageHeader.html" id="pageHeader"></div>
    +<div class="Layout">
    +<div class="Layout-sidebar" w3-include-html="/documentation/sidebar.html" id="sidebar"></div>
    +<div class="Layout-main markdown-body" id="mainContents">
    +
    +<blockquote>
    +The concept of determining a URL or
    +<span class="WebUISetting">Image Directory</span>
    +based on a file location on a server is very important,
    +but not very intuitive to most people.
    +You may need to read this section multiple times.
    +</blockquote>
    +<p>
    +When you enter a URL into a browser, how does the web server know where the file is?
    +It typically starts looking in the root of the website,
    +which is usually specified by the web server administrator.
    +When you go to the Allsky WebUI, for example via <b>http://allsky</b>,
    +Allsky looks in <span class="fileName">~/allsky/html</span>
    +since that's what the Allsky developers specified as the root of the WebUI.
    +</p>
    +<p>There are two paths to a web file - one via a URL and another
    +via the filesystem on the server.
    +The web server maps a URL to a file location on the server.
    +In our example above your Pi mapped <b>http://allsky</b> to
    +<span class="fileName">~/allsky/html</span>.
    +Sometimes that mapping is easy, and other times it's more difficult.
    +In both cases, it impacts what you enter into the
    +<span class="WebUISetting">Image Directory</span> setting for the remote
    +Website or Server.
    +</p>
    +
    +<blockquote>
    +The cases below assume you want to send your allsky images to a remote Allsky Website
    +but the same thing applies if you want to send the allsky images to remote server
    +that is not an Allsky Website.
    +</blockquote>
    +
    +<h4>Easy case: server directory structure matches URL</h4>
    +<p>
    +Let's say you have a personal website at <b>https://mysite.com</b>
    +where you store family photos.
    +When you access the server (usually via FTP) you see the family photos
    +without having to navigate anywhere.
    +This means your website URL maps to the top-most directory of the remote
    +server's directory structure, called the "root" directory.
    +</p>
    +<p>
    +To use that server as an Allsky Website you create a directory in
    +the root of the server called <span class="fileName">allsky</span>
    +and install an Allsky Website there.
    +(Visit the <a allsky="true" external="true"
    +	href="/documentation/installations/AllskyWebsite.html#onRemoteServer">
    +	Allsky Website Installation</a> page to see how).
    +</p>
    +<p>
    +The URL to your remote Allsky Website would be <b>https://mysite.com/allsky</b>.
    +In the WebUI you'd set the Remote Server's
    +<span class="WebUISetting">Image Directory</span>
    +to <span class="WebUIValue">/allsky</span>
    +(or <span class="WebUIValue">allsky</span> depending on your service provider).
    +</p>
    +
    +
    +<h4>More difficult case: directory structure does not match URL</h4>
    +<p>
    +You have the same website URL as above,
    +but when you access the remote server you don't see the photos;
    +instead, you see a directory called <span class="fileName">public_html</span>
    +(the actual directory(s) you see will depend on your service provider).
    +When you go into the <span class="fileName">public_html</span>
    +directory you see your photos.
    +</p>
    +<p>
    +In this case the web server is mapping the <b>https://mysite.com</b> URL to
    +your <span class="fileName">/public_html</span> directory.
    +You need to create a <span class="fileName">/public_html/allsky</span>
    +directory on the server and install the Allsky Website there.
    +The URL to the Allsky Website is still <b>https://mysite.com/allsky</b>
    +but in the WebUI you'd set the Remote Server's
    +<span class="WebUISetting">Image Directory</span>
    +to <span class="WebUIValue">/public_html/allsky</span>
    +(or <span class="WebUIValue">public_html/allsky</span> depending on your service provider).
    +</p>
    +<p>
    +Mapping URLs to server directories like this is fairly common on remote servers that
    +are shared by many people.
    +</p>
    +</details>
    +
    +
    +</div><!-- Layout-main -->
    +</div><!-- Layout -->
    +</body>
    +</html>
    +<script> includeHTML(); </script>
    diff --git a/html/documentation/explanations/startrails.html b/html/documentation/explanations/startrails.html
    index 546e9daaa..4341a6ada 100644
    --- a/html/documentation/explanations/startrails.html
    +++ b/html/documentation/explanations/startrails.html
    @@ -15,7 +15,9 @@
     		}
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
    +	<link href="../css/custom.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
     	<title>Startrails</title>
     </head>
     <body>
    @@ -25,15 +27,48 @@
     <div class="Layout-main markdown-body" id="mainContents">
     
     <p>
    -<b>Startrails</b> are images that contain all the images from a night on top of each other.
    +A <b>startrails</b> is an image that contains all the images from a night on top of each other,
    +so show the movement of stars.
    +<blockquote>
    +Only nighttime images that are saved are included in a startrails,
    +so if you don't save your nightime images, they won't be included.
    +</blockquote>
     </p>
    -<img allsky="true" src="startrails.png" title="Sample Startrails" class="imgCenter imgBorder" loading="lazy">
    +<img allsky="true" src="startrails.png" title="Sample Startrails"
    +	class="imgCenter imgBorder" loading="lazy">
     
    -<h2><code>startrails</code> program</h2>
    -<details><summary></summary>
    +<h2>Creating a startrails image</h2>
     <p>
    -The <code>startrails</code> program can take arguments:
    +You specify whether or not you want a startrails image automatically created via the
    +<a allsky="true" external="true" href="/documentation/settings/allsky.html#startrails">
    +	<span class="WebUISetting">Generate</span></a>
    +setting in the <span class="subSettingsHeader">Startrails</span> sub-section of the WebUI.
    +When enabled, a startrails will be created at the end of night.
     </p>
    +<p>
    +The easiest way to create and optionally upload a startrails is via the
    +<code>generateForDay.sh</code> command.
    +For example, to create and then upload a startrails to any Allsky Website and/or remote server 
    +you have for July 10, 2024:
    +<pre>
    +generateForDay.sh --startrails 20240710
    +generateForDay.sh --upload --startrails 20240710
    +</pre>
    +This will use the settings specified in the
    +<span class="subSettingsHeader">Startrails</span> sub-section of the WebUI.
    +</p>
    +<p>
    +<code>generateForDay.sh</code> calls the <code>startrails</code> program
    +to actually create the startrails, passing it several arguments
    +(the <u>underlined</u> ones below),
    +plus any others you add to the 
    +<span class="WebUISetting">Startrails Extra Parameters</span> setting.
    +</p>
    +<p>
    +You can execute the <code>startrails</code> program manually,
    +but will need to specify at least all the required arguments.
    +</p>
    +
     <pre>
     Usage: startrails [-v] -d &lt;dir&gt; -e &lt;ext&gt; [-b &lt;brightness&gt;] [-o &lt;output&gt;] [-S] [-s &lt;width&gt;x&lt;height&gt;]
     
    @@ -41,36 +76,23 @@ <h2><code>startrails</code> program</h2>
     -h : display this help, then exit
     -v : increase log verbosity
     -S : print image directory statistics without producing image.
    --d &lt;str&gt; : directory from which to read images
    --e &lt;str&gt; : filter images to just this extension
    --o &lt;str&gt; : output image filename
    +<u>-d &lt;str&gt; : directory from which to read images (required)</u>
    +<u>-e &lt;str&gt; : filter images to just this extension (required)</u>
    +<u>-o &lt;str&gt; : output image filename</u>
     -s &lt;int&gt;x&lt;int&gt; : restrict processed images to this size
    --b &lt;float&gt; : ranges from 0 (black) to 1 (white).
    -        A moonless sky may be as low as 0.05 while full moon can be as high as 0.4
    +<u>-b &lt;float&gt; : ranges from 0 (black) to 1 (white).</u>
    +        A moonless sky may be as low as 0.05 while full moon can be as high as 0.4.
     </pre>
     
     <br><p>
    -The only configuration option for startrails in <span class="fileName">config.sh</span> is
    -<span class="shSetting">BRIGHTNESS_THRESHOLD</span> which defaults to
    -<span class="editorNum">0.1</span>.
    -Any image with an average brightness greater than this will
    -be skipped during startrails image generation,
    -so <b>almost all daytime images are skipped</b>.
    -You may need to play around with this to get the best results,
    -as allsky cameras, lenses, and sky brightnesses vary from person to person.
    -</p>
    -
    -Example when running the program manually:
    +Example of running the <code>startrails</code> program manually:
     <pre>
    -cd ~/allsky
    -bin/startrails -d images/20240223/ -e jpg -b 0.15 -o images/20240223/startrails/startrails-20240223.jpg
    +cd ~/allsky/bin
    +./startrails -d images/20240710 -e jpg -b 0.15 -o images/20240710/startrails/startrails.jpg
     </pre>
    +</p>
     
    -<br><p>
    -To disable automatic startrails, open <span class="fileName">config.sh</span> and set
    -<span class="shSetting">STARTRAILS</span> to "false".
    -<p>
    -
    +<!-- THESE SETTINGS NO LONGER EXIST
     <blockquote>
     <b>Tip</b>: If you set the
     <span class="WebUISetting">Image Width</span>
    @@ -81,56 +103,88 @@ <h2><code>startrails</code> program</h2>
     startrails generation will skip any file that's not the correct size.
     This will eliminate any garbage images that happen to be generated.
     </blockquote>
    +-->
     
    -</details>
    -
    -
    -<h2>Troubleshooting</h2>
    -<details><summary></summary>
    +<h2 id="brightnessThreshold"><span class="WebUISetting">Brightness Threshold</span></h2>
    +<p>
    +The only configuration setting for startrails is
    +<span class="WebUISetting">Brightness Threshold</span> which defaults to
    +<span class="WebUIValue">0.1</span>.
    +Any image with an average brightness greater than this will
    +be skipped during startrails image generation,
    +so <b>almost all daytime images are skipped</b>.
    +You need to experiment with this to get the best results,
    +as allsky cameras, lenses, and sky brightnesses impact this setting.
    +</p>
     <p>
     If your startrails aren't working and you get a message
     <b>No images below threshold 0.100, writing the minimum image only</b>,
     this means all your images are too bright.
     If startrails previously worked, did you recently update the
    -<span class="shSetting">BRIGHTNESS_THRESHOLD</span> setting in
    -<span class="fileName">config.sh</span>?
    +<span class="WebUISetting">Brightness Threshold</span> setting?
     If so, set it back to what it used to be.
     </p>
    -
    -The key to getting startrails to work is making sure
    -<span class="shSetting">BRIGHTNESS_THRESHOLD</span>
    -is correct for your skies.
    -To do this you need to know typical <b>nighttime</b> sky brightness values.
    +<p>
    +To determine what to use for the
    +<span class="WebUISetting">Brightness Threshold</span>,
    +you need to know the typical <b>nighttime</b> sky brightness values.
     Do the following (replace "DATE" below with the date of a non-working startrails):
    -<ul>
    -<li>Create a temporary directory to hold nighttime images:
    -	<code>mkdir ~/allsky/images/test</code>.
    -<li>Open a "File Manager" window and go into <span class="fileName">allsky/images/DATE</span>.
    -<li>Using the time of each image, move the <b>nighttime</b> files to
    -	<span class="fileName">allsky/images/test</span> (select with mouse,
    -	then drag to the <span class="fileName">test</span> directory).
    -	<b>nighttime</b> includes any file you want in the startrails image.
    -<li>Create a startrails file in the <span class="fileName">test</span> directory
    -	which contains only nighttime images: <code>generateForDay.sh --startrails test</code>.
    -<li>It should say <b>No images below...</b>.
    -<li>Look at the "Minimum..." line.
    -	Set the <span class="shSetting">BRIGHTNESS_THRESHOLD</span>
    -	to the maximum, or slightly below it.
    -<li><code>generateForDay.sh --startrails test &nbsp; &nbsp; # this should give pretty good results</code>
    -<li>Adjust <span class="shSetting">BRIGHTNESS_THRESHOLD</span>
    -	and re-run <code>generateForDay.sh</code> as needed.
    -<li>When done move all the images in <span class="fileName">allsky/images/test</span>
    -	back to <span class="fileName">allsky/images/DATE</span>.
    -<li><code>rm -fr ~/allsky/images/test &nbsp; &nbsp; # remove the temporary directory</code>
    -<li>Now, create the final startrails:
    -	<ul>
    -	<li><code>generateForDay.sh --startrails DATE</code>
    -	<li>If you want to upload the <span class="fileName">startrails.jpg</span>
    -		file you just created, see the note generated by <code>generateForDay.sh</code>.
    -	</ul>
    -</ul>
    -</details>
    +<ol>
    +	<li>Create a temporary directory to hold nighttime images:
    +		<code>mkdir ~/allsky/images/test</code>.
    +	<li><code>cd ~/allsky/images/DATE</code>.
    +	<li>Using the time of each image, move a few hours of <b>nighttime</b> images to
    +		<span class="fileName">allsky/images/test</span>.
    +		This is easiest if you first open a "File Manager" window on the Pi -
    +		you can then select the files with the mouse and
    +		drag to the <span class="fileName">test</span> directory.
    +		<br>If you aren't logged into the Pi desktop you'll need to use
    +		the <code>mv</code> command to move the files.
    +		Using the <code>*</code> wildcard in the file names will allow you to move
    +		multiple files at a time.
    +	<li>Run <code>generateForDay.sh --startrails test</code>
    +		to create a startrails file in the <span class="fileName">test</span> directory.
    +		It should say <b>No images below...</b>.
    +	<li>Look at the output line showing the minimum and maximum.
    +		Set the <span class="WebUISetting">Brightness Threshold</span>
    +		to the <strong>maximum</strong>, or slightly below it.
    +	<li>Run <code>generateForDay.sh --startrails test</code>
    +		to create a new startrails - it should give a decent result.
    +	<li>Adjust <span class="WebUISetting">Brightness Threshold</span>
    +		and re-run <code>generateForDay.sh</code> as needed.
    +	<li>When done move all the images in <span class="fileName">allsky/images/test</span>
    +		back to <span class="fileName">allsky/images/DATE</span>.
    +	<li>Remove the temporary directory: <code>rm -fr ~/allsky/images/test</code>.
    +	<li>Now, create the final startrails:
    +		<ul class="minimalPadding topPadding">
    +			<li><code>generateForDay.sh --startrails DATE</code>
    +			<li>If you want to upload the <span class="fileName">startrails.jpg</span>
    +				file you just created, see the note generated by <code>generateForDay.sh</code>.
    +		</ul>
    +</ol>
    +</p>
     
    +<h2>Troubleshooting</h2>
    +<p>
    +If a startrails isn't being created, make sure the
    +<span class="WebUISetting">Generate</span> setting is enabled.
    +If that IS enabled, run:
    +<pre>generateForDay.sh --startrails DATE</pre>
    +and check for errors.
    +<blockquote>
    +It is extremely rare that a startrails isn't created at all.
    +It's much more common that only 1 image is used -
    +in that case, see the <a href="#brightnessThreshold">Brightness Threshold</a> section above.
    +</blockquote>
    +</p>
    +<p>
    +If a startrails isn't being uploaded, make sure the
    +<span class="WebUISetting">Upload</span> setting is enabled.
    +If that IS enabled, run:
    +<pre>generateForDay.sh --upload --debug --startrails DATE</pre>
    +and check for errors.
    +If needed, run <code>testUpload.sh</code> to see why the upload fails.
    +</p>
     
     </div><!-- Layout-main -->
     </div><!-- Layout -->
    diff --git a/html/documentation/explanations/timelapses.html b/html/documentation/explanations/timelapses.html
    new file mode 100644
    index 000000000..e72cc950a
    --- /dev/null
    +++ b/html/documentation/explanations/timelapses.html
    @@ -0,0 +1,137 @@
    +<!DOCTYPE html>
    +<html lang="en">
    +<head>
    +	<meta charset="utf-8">
    +	<meta http-equiv="X-UA-Compatible" content="IE=edge">
    +	<meta name="viewport" content="width=device-width, initial-scale=1">
    +	<script src="../js/documentation.js" type="application/javascript"></script>
    +	<link href="../css/light.css" rel="stylesheet">
    +	<link href="../bower_components/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
    +	<script src="../bower_components/jquery/dist/jquery.min.js"></script>
    +	<script src="../bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
    +	<style>
    +		#pageTitle::before {
    +			content: "Timelapses Explained";
    +		}
    +	</style>
    +	<link href="../css/documentation.css" rel="stylesheet">
    +	<link href="../css/custom.css" rel="stylesheet">
    +	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
    +	<title>Timelapses</title>
    +</head>
    +<body>
    +<div w3-include-html="/documentation/pageHeader.html" id="pageHeader"></div>
    +<div class="Layout">
    +<div class="Layout-sidebar" w3-include-html="/documentation/sidebar.html" id="sidebar"></div>
    +<div class="Layout-main markdown-body" id="mainContents">
    +
    +<p>
    +A <b>timelapse</b> is a video that contains multiple still image and shows changes over time.
    +For example, you can watch the clouds move during the day or the stars move at night.
    +</p>
    +<p>
    +Allsky supports two types of timelapse:
    +<ol class="minimalPadding">
    +	<li><strong>Daily Timelapse</strong>
    +	<li><strong>Mini Timelapse</strong>
    +</ol>
    +Settings for both types of timelapses are in the
    +<a allsky="true" external="true" href="/documentation/settings/allsky.html#timelapse">
    +	<span class="settingsHeader">Timelapse Settings</span></a>
    +section of the WebUI's <span class="WebUILink">Allsky Settings</span> page.
    +Each type of timelapse has its own sub-section in the WebUI,
    +and there is a sub-section that applies to both timelapse types.
    +</p>
    +
    +<h2>Daily Timelapse</h2>
    +<p>
    +You specify whether or not you want a daily timelapse automatically created via the
    +<a allsky="true" external="true" href="/documentation/settings/allsky.html#dailytimelapse">
    +	<span class="WebUISetting">Generate</span></a>
    +setting.
    +When enabled, a daily timelapse will be created (and optionally uploaded) at the end of night.
    +</p>
    +<p>
    +The easiest way to <strong>manually</strong> create and optionally upload a
    +daily timelapse is via the <code>generateForDay.sh</code> command.
    +For example, to create and then upload a daily timelapse to any Allsky Website and/or remote server 
    +you have for July 10, 2024:
    +<pre>
    +generateForDay.sh --timelapse 20240710
    +generateForDay.sh --upload --timelapse 20240710
    +</pre>
    +This will use the settings specified in the
    +<span class="subSettingsHeader">Daily Timelapse</span> sub-section of the WebUI.
    +</p>
    +<p>
    +If you have a remote Website you'll most likely need to enable the
    +<span class="WebUISetting">Upload Thumbnail</span> setting
    +so the timelapse's thumbnail is created on the Pi and uploaded to the Website.
    +</p>
    +<p>
    +If your camera has a lot of pixels you may need to resize the timelapse
    +in order to decrease the processing power needed to create it and to reduce the file size.
    +If so, update the
    +<span class="WebUISetting">Width</span> and <span class="WebUISetting">Height</span>
    +settings.
    +Cutting each size in half is a good starting point.
    +</p>
    +
    +
    +<h2>Mini Timelapse</h2>
    +<p>
    +A mini timelapse contains a limited number of images and is constantly recreated
    +throughout the day.
    +For example, you can have a mini timelapse that shows the last 50 of images,
    +and is recreated every 5 images.
    +Note that every new mini timelapse <strong>replaces</strong> the prior one,
    +so there is ever only one mini timelapse.
    +</p>
    +<p>
    +You specify whether or not you want a mini timelapse created via the
    +<a allsky="true" external="true" href="/documentation/settings/allsky.html#minitimelapse">
    +	<span class="WebUISetting">Number Of Images</span></a>
    +setting.
    +If greater than <span class="WebUIValue">0</span>,
    +mini timelapses will be created (and optionally uploaded) containing
    +that number of images.
    +You can enter any number you want, but beware:
    +<ul class="minimalPadding">
    +	<li>A small number of images will produce a very short video.
    +		For example, a video with <span class="WebUIValue">5</span> images will
    +		usually last less than a second.
    +	<li>A large number of images will take longer to create and depending on
    +		the speed of your Pi, could cause other things to run slowly.
    +	<li>On a Pi 4, try starting with <span class="WebUIValue">50</span> images
    +		and adjust as needed.
    +</ul>
    +</p>
    +<p>
    +If <span class="WebUISetting">Number Of Images</span> is greater than
    +<span class="WebUIValue">0</span>,
    +a new mini timelapse will be created after the number of images you specify in
    +<span class="WebUISetting">Frequency</span>.
    +The smaller the number the more often a mini timelapse will be created,
    +and the more processing power needed.
    +Try starting off at <span class="WebUIValue">5</span> and adjust as needed.
    +</p>
    +<p>
    +Mini timelapses are not designed to be created manually
    +because some configuration files need to be updated after creation.
    +</p>
    +
    +
    +<h2>Troubleshooting</h2>
    +<p>
    +See the <a allsky="true" external="true" href="/documentation/troubleshooting/timelapse.html">
    +	Troubleshooting -> Timelapse</a>
    +page for troubleshooting information.
    +</p>
    +
    +</div><!-- Layout-main -->
    +</div><!-- Layout -->
    +</body>
    +</html>
    +<script> includeHTML(); </script>
    +
    diff --git a/html/documentation/img/allsky-logo.png b/html/documentation/img/allsky-logo.png
    index dcb202e74..8fc3b426f 100644
    Binary files a/html/documentation/img/allsky-logo.png and b/html/documentation/img/allsky-logo.png differ
    diff --git a/html/documentation/index.html b/html/documentation/index.html
    index e5d9049ff..e5d3a9315 100644
    --- a/html/documentation/index.html
    +++ b/html/documentation/index.html
    @@ -16,7 +16,7 @@
     	</style>
     	<link href="css/documentation.css" rel="stylesheet">
     	<link href="documentation-favicon.ico" rel="shortcut icon" type="image/png">
    -	<title>AllSky Documentation</title>
    +	<title>Allsky Documentation</title>
     </head>
     <body>
     <div w3-include-html="/documentation/pageHeader.html" id="pageHeader"></div>
    @@ -25,10 +25,10 @@
     <div class="Layout-main markdown-body" id="mainContents">
     
     <p>
    -The Allsky documentation provides information for
    -<a allsky="true" href="installations/Allsky.html">installing</a>,
    -<a allsky="true" href="settings/allsky.html">configuring</a>,
    -and running the AllSky software,
    +This documentation provides information for
    +<a ="true" href="installations/.html">installing</a>,
    +<a ="true" href="settings/.html">configuring</a>,
    +and running the Allsky software,
     including describing what various features like
     <a allsky="true" href="overlays/overlays.html">overlays</a>
     and
    @@ -36,35 +36,32 @@
     are, and how to troubleshoot and fix problems.
     </p>
     <p>
    -This documentation is on your Pi.
    -A copy is also on the
    -<a href="https://github.com/AllskyTeam/allsky">Allsky GitHub</a> page,
    -which may be more up-to-date than what's on your Pi,
    -depending on when you last updated Allsky.
    -Major changes to the documenation will result in a new Allsky release which you can update to.
    +You can accesss this documentation via the WebUI's
    +<span class="WebUIWebPage">Allsky Documentation</span> link or the
    +<span class="gitHubLink">Wiki</span> link on any
    +<a href="https://github.com/AllskyTeam/allsky">Allsky GitHub</a> page.
     </p>
     
     <h1>Questions and Feature Requests</h1>
     If you have a <b>QUESTION</b> or want to <b>REQUEST A NEW FEATURE</b>
     create a new Discussion by clicking on the
    -<a href="https://github.com/AllskyTeam/allsky/discussions"><code>Discussions</code></a>
    +<a href="https://github.com/AllskyTeam/allsky/discussions">
    +<span class="gitHubLink">Discussions</span></a>
     link on any Allsky GitHub page.
     Please do <b>NOT</b> create an Issue.
     
     <h1>Issues</h1>
     If you have a <b>problem</b> first look in the
    -"Troubleshooting" pages of the documentation for a solution - most known problems are listed there.
    -If you don't find an answer, look in the "Troubleshooting" section of the
    -<a href="https://github.com/AllskyTeam/allsky/wiki">Wiki</a>
    -since GitHub may be more up-to-date than your Pi.
    -
    -If you can't find a similar problem, read the
    +"Documentation -&gt; Troubleshooting" pages for a solution - most known problems are listed there.
    +If you can't find a similar problem look in the
    +<span class="gitHubLink">Discussions</span> (it may be a closed discussion).
    +If you still can't find your problem, read the
     <a allsky="true" href="troubleshooting/reportingIssues.html">Reporting Issues</a>
    -page and then create a new Issue. 
    +page and then create a new <span class="gitHubLink">Issue</span>.
     
     <br><br>
     <p>
    -Thanks - the developers
    +Thanks - the Allsky Team
     </p>
     
     </div><!-- Layout-main -->
    diff --git a/html/documentation/installations/Allsky.html b/html/documentation/installations/Allsky.html
    index ce5fa7cea..e6cab0d9b 100644
    --- a/html/documentation/installations/Allsky.html
    +++ b/html/documentation/installations/Allsky.html
    @@ -12,10 +12,11 @@
     	<style>
     		#pageTitle::before {
     			content: "Installing and upgrading Allsky";
    -		} 
    +		}
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
     	<title>Installing and upgrading Allsky</title>
     </head>
     <body>
    @@ -27,77 +28,39 @@
     <p>
     This page describes how to install and upgrade Allsky.
     </p>
    -<p>
    -After Allsky is running and you've configured it to your satisfaction,
    -you may want to install the Allsky Website on your Pi and/or on a remote server.
    -See the <a allsky="true" href="AllskyWebsite.html">Allsky Website Installation</a> page for details.
    -</p>
     <blockquote>
    -This release of Allsky has many updates so we suggest
    -you <b>install the newest <a href="https://www.raspberrypi.com/software/">Raspberry Pi OS</a>
    -(Bullseye) on your Pi</b>,
    -before installing this release of Allsky.
    -You'll first need to save any files, images, etc. to a USB drive, PC, Mac, or another device,
    -then install the new Pi operating system (OS), and then restore your files before installing Allsky.
    -During installation of the new Pi OS, if asked for the name of a user account to create,
    -enter <code>pi</code>.
    +We highly suggest installing the current version of Allsky on a clean SD card
    +since a lot of files have changed and using a clean card will ensure a clutter-free environment
    +with the most recent commands.
     <br>
    -The process is much simplier if you have a spare SD card you can install the new
    -Pi OS version on, and have an SD card-to-USB adapter so you can plug the old SD card into
    -the Pi after the new OS is installed.
    -Copying your old files is then a breeze.
    +See the instructions on
    +<a allsky="true" href="../explanations/imageSDcard.html" external="true">
    +how to image an SD card for use by Allsky.</a>
     </blockquote>
     
    -<h2>Install AllSky in a different location (advanced)</h2>
    -<details><summary></summary>
    -
    -<blockquote>
    -Only advanced users should consider installing Allsky in a different location.
    -All the documentation assumes it's installed in the default location,
    -so it may be more difficult to troubleshoot if it's elsewhere.
    -</blockquote>
    -<p>
    -By default, Allsky is installed as the login "pi" in <span class="fileName">/home/pi/allsky</span>.
    -The name <span class="fileName">allsky</span> can not be changed,
    -but you can install Allsky in a different directory
    -(which usually means it'll run as a different login).
    -For example, to install in <span class="fileName">/home/mylogin</span>
    -and run as the "mylogin" user, do the following before installing Allsky:
    -<pre>
    -sudo mkdir /home/mylogin      <span class="pl-c"># if not already there</span>
    -sudo addgroup mylogin sudo    <span class="pl-c"># allows Allsky to execute system commands</span>
    -cd /home/mylogin
    -</pre>
    -After the above, continue with the steps below.
    -</details>
    -
    -
     <h2>If a version of Allsky already exists</h2>
     <details><summary></summary>
     <p>
    -If you have an <b>existing</b> version of Allsky,
    -stop it
    +If you have an <b>existing</b> version of Allsky, stop it:
     <pre>
     sudo systemctl stop allsky
     cd
     </pre>
    -
    -then perform one of the steps below:
     </p>
    -<ol class="minimalPadding">
    -	<li>To <b>upgrade</b> the old version:
    -	<pre>mv allsky allsky-OLD         <span class="pl-c"># The installation looks for this directory</span></pre>
    +then perform <strong>one</strong> of these steps:
    +<ol>
    +	<li>To <b>upgrade</b> the old version and keep its settings:
    +	<pre>mv  allsky  allsky-OLD</pre>
     
     	<li>To <b>save</b> the old version but not use it:
     	<br>
    -	<pre>mv allsky allsky-SAVED</pre>
    -	At some point you'll want to delete the SAVED version so it doesn't take disk space.
    +	<pre>mv  allsky  allsky-SAVED</pre>
    +	At some point you'll want to delete the SAVED version so it doesn't use disk space.
     
    -	<li>To <b>delete</b> the old version.
    -		Only select this option if you're sure you don't want any saved
    -		images, darks, and configuration settings.
    -	Either way, execute:
    -	<pre>rm -fr allsky</pre>
    +	<li>To <b>delete</b> the old version -
    +		only select this option if you're sure you don't want any saved
    +		images, darks, and configuration settings:
    +	<pre>rm  -fr  allsky</pre>
     </ol>
     <p>
     Continue to the <a href="#PreInstallation">Pre installation</a> section.
    @@ -108,19 +71,20 @@ <h2>If a version of Allsky already exists</h2>
     <h2 id=PreInstallation>Pre installation</h2>
     <details><summary></summary>
     <p>
    -Prior to installing Allsky, there are a few things you need to do.
    -These only need to be done once.
    +The following needs to be done once prior to installing Allsky:
     <ol>
    -	<li>If this is a new Pi, you'll need to install the Raspberry Pi Operating System (OS) on it.
    -		Follow
    -		<a href="https://www.raspberrypi.org/documentation/installation/installing-images/">these instructions</a>
    -		for information on how to do it.  Be sure to install the newest OS.
    +	<li>If this is a new Pi, you'll need to
    +		<a allsky="true" href="../explanations/imageSDcard.html" external="true">
    +		install the Raspberry Pi Operating System (OS)</a> on it.
     	<li>Make sure your Pi has a
    -		<a href="https://www.raspberrypi.org/documentation/configuration/wireless/wireless-cli.md">working Internet connection</a>.
    +		<a href="https://www.raspberrypi.org/documentation/configuration/wireless/wireless-cli.md" external="true">working Internet connection</a>.
     		If you have a choice between a wired LAN and wireless WLAN connection,
     		choose the wired connect - they are faster and more reliable.
    -		If you use Power over Ethernet (PoE), you can run a single cable to your Pi.
    -	<li>Install <code>git</code> if not already installed (you only need to do this once):
    +		If you use
    +		<a href="https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#power-over-ethernet-poe-connector" external="true">
    +		Power over Ethernet (PoE)</a>,
    +		you can run a single ethernet cable to your Pi.
    +	<li>Ensure <code>git</code> is installed:
     		<pre>sudo apt-get install git</pre>
     </p>
     </details>
    @@ -128,76 +92,67 @@ <h2 id=PreInstallation>Pre installation</h2>
     
     <h2 id="Installation">Installation</h2>
     <details><summary></summary>
    -<blockquote>
    -Unlike prior versions of AllSky this version requires that the camera be
    -connected prior to starting the installation process.
    -During installation, the camera model will be automatically determined
    -and the appropriate minimum, maximum, and default settings populated in the WebUI.
    -</blockquote>
     <p>
    -The following commands will put the new release of Allsky in <span class="fileName">~/allsky</span>.
    -Except for some system files, all Allsky-related files reside in this directory.
    -If you later install the Allsky Website on the Pi, its files will also go in this directory.
    +The following commands put the new release of Allsky in
    +<span class="fileName">~/allsky</span>.
    +Except for some system files,
    +all Allsky-related files reside in this directory.
     </p>
     
     <pre>
     cd
    -git clone --recursive https://github.com/AllskyTeam/allsky.git
    +git clone  --depth=1  --recursive  https://github.com/AllskyTeam/allsky.git
     cd allsky
     ./install.sh</pre>
     <p>
    -The <code>git clone</code> will take a couple minutes and should produce output similar to
    -what's below.
    -The new <span class="fileName">allsky</span> directory is approximately 150 MB after download.
    +The <code>git clone</code> command will take a couple minutes and should
    +produce output similar to what's below.
    +The new <span class="fileName">allsky</span> directory is approximately 75 MB after download.
     <pre>
     Cloning into 'allsky'...
     remote: Enumerating objects: 16464, done.
    -remote: Counting objects: 100% (494/494), done.
    -remote: Compressing objects: 100% (395/395), done.
    -remote: Total 16464 (delta 275), reused 220 (delta 96), pack-reused 15970
    +<span class="pl-c">...  more commands here</span>
     Receiving objects: 100% (16464/16464), 94.03 MiB | 1.13 MiB/s, done.
     Resolving deltas: 100% (9845/9845), done.
    -Submodule 'src/sunwait-src' (https://github.com/risacher/sunwait) registered for path 'src/sunwait-src'
    +Submodule 'src/sunwait-src' (https://github.com/risacher/sunwait) registered for ...
     Cloning into '/home/pi/allsky/src/sunwait-src'...
    -remote: Enumerating objects: 130, done.        
    -remote: Counting objects: 100% (82/82), done.        
    -remote: Compressing objects: 100% (37/37), done.        
    -remote: Total 130 (delta 60), reused 53 (delta 45), pack-reused 48        
    -Receiving objects: 100% (130/130), 125.99 KiB | 848.00 KiB/s, done.
    +remote: Enumerating objects: 130, done.
    +<span class="pl-c">...  more commands here</span>
     Resolving deltas: 100% (72/72), done.
     Submodule path 'src/sunwait-src': checked out '102cb417ecbb7a3757ba9ee4b94d6db3225124c4'
     </pre>
     <p>
    -The installation prompts for several items that allow it to configure the Pi so
    -Allsky runs most efficiently given your Pi's configuration.
    -These prompts include:
    +The Allsky installation prompts for several items including:
     <ul>
    -	<li>New host name, if not the default of <b>allsky</b>.
    -		If you have more than one Pi on the same network they must all have unique names.
    -		For example, if you have a test Pi you may want to call it <span class="fileName">allsky-test</span>.
    -	<li>Adding swap space if needed.
    +	<li>New host name, if not the default of <code>allsky</code>.
    +		If you have more than one Pi on the same network they
    +		<strong>must</strong> all have unique names.
    +		For example, if you have a test Pi you may want to call it <code>allsky-test</code>.
    +	<li><strong>Locale</strong> to use.
    +		This determines what the decimal separator is in log output
    +		(period or comma).
    +		<br>Note that the default locale is <code>en_GB.UTF-8</code> where the Pi is developed.
    +	<li>Adding <strong>swap space</strong> if needed.
     		Swap space effectively increases the amount of memory your Pi has.
     		Insufficient swap space is one of the leading causes of timelapse video creations problems.
    -	<li>Putting the <span class="fileName">~/allsky/tmp</span> directory in memory instead of on the disk.
    -		This directory holds temporary Allsky files and is where most Allsky files are written to.
    +	<li>Putting the <span class="fileName">~/allsky/tmp</span>
    +		directory in memory instead of on the disk.
    +		This directory holds temporary Allsky files and is where most Allsky files are
    +		initially written to.
     		Putting the directory in memory <b>significantly</b> reduces the number of writes
     		to the SD card, which increases its life.
     </ul>
    -It's highly recommended to accept the defaults.
    +You should normally accept the defaults.
     </p>
     <p>
     During installation you'll be notified of any actions you need
     to take when the installation completes.
    -If there are any such actions, the first time Allsky runs after the reboot it will stop and:
    -<ul>
    -	<li>add those actions to the log file
    -	<li>display an image telling you to see the log file
    -	<li>display a message in the WebUI (which you should clear when done performing the actions)
    -</ul>
    +If there are any such actions, the first time Allsky runs after the reboot it will stop and
    +display a message in the WebUI (which you should clear when done performing the actions).
     </p>
     <p>
     <blockquote>
    -Parts of the installation take up to an hour, depending on how many package
    +The installation <i>may</i> take up to an hour, depending on how many package
     you already have installed and the speed of your Pi.
     Subsequent installations of Allsky will be significantly faster.
     </blockquote>
    @@ -208,15 +163,9 @@ <h2 id="Installation">Installation</h2>
     <h2>Post installation</h2>
     <details><summary></summary>
     <p>
    -After installation, reboot then perform any actions the installation script identified.
    -</p>
    -<blockquote>
    -If instructed to copy the contents of a prior configuration file to the new release,
    -it's important to manually copy the <b>values</b>
    -from the old file to the new file and <b>not</b> copy the entire file.
    -</blockquote>
    -
    -After the reboot, do the following:
    +After installation, reboot if told to,
    +then perform any actions the installation script identified.
    +Allsky will not begin until you do the following:
     <ol>
     	<li>Bring up the WebUI by entering
     		<code>http://allsky.local</code> or
    @@ -228,44 +177,29 @@ <h2>Post installation</h2>
     		</blockquote>
     
     	<li>Go to the <span class="WebUILink">Allsky Settings</span> page.
    -	<li>Make any necessary changes.
    +	<li>Make any necessary and/or desired changes.
     	<li>Click on the
     		<span class="btn btn-primary btn-not-real btn-small">Save changes</span> button.
    -		Allsky will (re)start.
    -	<li>If instructed to update the <span class="fileName">config.sh</span>
    -		and/or the <span class="fileName">ftp-settings.sh</span> files:
    -		<ol>
    -			<li>Go to the <span class="WebUILink">Editor</span> page.
    -			<li>Select the appropriate file in the drop down list.
    -			<li>Edit the file.
    -				Do NOT simply copy a previous version.
    -			<li>Click the green "Save Changes" button.
    -				Allsky will not restart but the changes will take effect when the next
    -				image is taken.
    -		</ol>
    +		Allsky will start.
     </ol>
    -
    -<blockquote class="warning">
    -The <span class="fileName">config.sh</span> and
    -<span class="fileName">ftp-settings.sh</span> configuration files should
    -<b>only</b> be edited via the
    -WebUI's <b>Editor</b> link since it performs sanity checks and behind-the-scenes
    -actions during updates.
    -</blockquote>
     </details>
     
     
     <h2>Starting and stopping Allsky</h2>
     <details><summary></summary>
     <p>
    -AllSky starts automatically when the Raspberry Pi boots up.
    +Allsky starts automatically when the Raspberry Pi boots up.
     To enable or disable this behavior, use these commands:
     <pre>
    -sudo systemctl enable allsky     <span class="shellComment"># starts the software when the Pi boots up</span>
    -sudo systemctl disable allsky    <span class="shellComment"># does NOT automatically start the software</span>
    +sudo systemctl enable allsky     <span class="shellComment"># starts Allsky when the Pi boots up</span>
    +<span class="shellComment">#   OR</span>
    +sudo systemctl disable allsky    <span class="shellComment"># does NOT automatically start Allsky</span>
     </pre>
    +</p>
     
    -When you want to manually start, stop, or restart the software, or obtain status, use one of these commands:
    +<p>
    +When you want to manually start, stop, or restart Allsky,
    +or obtain status, use one of these commands:
     <pre>
     sudo systemctl start allsky
     sudo systemctl stop allsky
    @@ -279,15 +213,15 @@ <h2>Starting and stopping Allsky</h2>
     <pre>
     alias start='sudo systemctl start allsky'
     </pre>
    -You will then only need to type <code>start</code> to start Allsky.
    -Do this for <b>stop</b> and <b>reload</b> as well.
    +You then only need to type <code>start</code> to start Allsky.
    +Do this for the other commands as well.
     </blockquote>
     </p>
     
     <p>
    -Starting the program from the terminal is a great way to track down issues as
    +Starting Allsky from the terminal is a great way to track down issues as
     it provides debug information to the terminal window.
    -To start the program manually, run:
    +To start Allsky manually, run:
     <pre>
     sudo systemctl stop allsky
     cd ~/allsky
    @@ -297,17 +231,11 @@ <h2>Starting and stopping Allsky</h2>
     <p>
     If you are using a desktop environment (Pixel, Mate, LXDE, etc.) or using remote desktop or VNC,
     you can add the <code>preview</code> argument to show the images the program is
    -currently saving in a separate window.
    +currently saving in a separate window:
     <pre>
     ./allsky.sh preview
     </pre>
     </p>
    -<p>
    -<blockquote>
    -The <b>Allsky Website</b> normally does not need to be started or stopped,
    -but if you need to do so run <code>systemctl start lighttpd</code>.
    -</blockquote>
    -</p>
     </details>
     
     
    diff --git a/html/documentation/installations/AllskyWebsite.html b/html/documentation/installations/AllskyWebsite.html
    index 68ae2e812..675afe30b 100644
    --- a/html/documentation/installations/AllskyWebsite.html
    +++ b/html/documentation/installations/AllskyWebsite.html
    @@ -11,13 +11,14 @@
     	<script src="../bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
     	<style>
     		#pageTitle::before {
    -			content: "Installing and upgrading Allsky Website";
    +			content: "Installing and upgrading an Allsky Website";
     		} 
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
    +	<link href="../css/custom.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
     	<script src="../js/all.min.js" type="application/javascript"></script>
    -	<title>Installing and upgrading Allsky Website</title>
    +	<title>Installing an Allsky Website</title>
     </head>
     <body>
     <div w3-include-html="/documentation/pageHeader.html" id="pageHeader"></div>
    @@ -26,180 +27,117 @@
     <div class="Layout-main markdown-body" id="mainContents">
     
     <p>
    -This page describes how to install and upgrade the Allsky Website.
    -It can be installed on your Pi, on a remote server, or on both.
    -When installing on both, install on your Pi first and make sure everything works,
    -then install on the remote server.
    -</p>
    -<p>
    -Make sure you have Allsky configured and working the way you want it
    -since some of its settings are also used by the Website
    +The code for the Allsky Website is installed as part of the Allsky installation on your Pi,
    +so no installation is necessary on the Pi,
    +although it needs to be configured before it can be used.
    +Make sure you have Allsky configured and working the way you want it before
    +configuring a Website since some of the Allsky settings are also used by the Website
     and you don't want to have to change them twice.
     </p>
     
    -<h2>Install on a Pi</h2>
    -<details><summary></summary>
     <p>
     If you have an <b>existing</b> Allsky Website on your Pi,
    -see the section below, otherwise
    -skip to the <a href="#installation-local">Installing the Website</a> section below.
    -</p>
    -
    -<h4>Steps for existing Websites</h4>
    -<p>
    -The location of the prior website depends upon its version.
    -In both cases the directory name is <span class="fileName">allsky</span>.
    -The parent directory is:
    -<ul>
    -	<li><span class="fileName">/var/www/html</span>
    -		for Website version <b>v2022.03.01+</b> or before.
    -	<li><span class="fileName">~/allsky/html</span>
    -		for version <b>v2023.05.01</b> or later.
    -</ul>
    -</p>
    -
    -<p>
    -Perform <strong>one</strong> of the steps below,
    -then continue to the <a href="#installation-local">Installing the Website</a> section:
    +the installation of Allsky will move images and videos from the prior Website's directory
    +to the new Website's directory.
    +If the prior Website was version <b>v2022.03.01+</b> or earlier you need to:
     <ol>
    -<li><b>Upgrade the old version.</b>
    -	<br>
    -	The Website installation checks for an old version in the locations above,
    -	and if found, prompts if you want to upgrade it.
    -	You don't need to do anything special.
    -
    -<li><b>Save the old version but don't use it.</b>
    -	<br>
    -	<pre>
    -cd <i>old_version_parent_directory</i>     <span class="shellComment"># One of the locations above</span>
    -mv allsky allsky-SAVED</pre>
    -	At some point you'll want to delete the SAVED version so it doesn't take disk space.
    -
    -<li><b>Delete the old version.</b>
    -	<br>
    -	Only select this option if you're sure you don't want any saved
    -	images, videos, keograms, startrails, or settings files.
    -<pre>
    -cd <i>old_version_parent_directory</i>     <span class="shellComment"># One of the locations above</span>
    -rm -fr allsky</pre>
    +	<li><em>Manually</em> copy your prior settings from 
    +		<span class="fileName">/var/www/html/allsky/config.js</span> to
    +		<span class="fileName">configuration.json</span> by going to the 
    +		<span class="WebUIWebPage">Editor</span> page in the WebUI and selecting
    +		<code>configuration.json (local Allsky Website)</code>.
    +		Note that there are more settings in the new configuration file,
    +		but it should be straightforward to map settings from the old file to the new file.
    +	<li>After configuring the local Website,
    +		check in <span class="fileName">/var/www/html/allsky</span>
    +		for any files you manually added and store them in
    +		<span class="fileName">~/allsky/html/allsky/myFiles</span>.
    +	<li>Save <span class="fileName">config.js</span> somewhere in case you need it later.
    +	<li>Remove the old Website: <pre> rm -fr /var/www/html/allsky </pre>
     </ol>
     </p>
     
     
    -<h4 id="installation-local">Installing the Website</h4>
    -<p>
    -Run the following to install the Website on your Pi:
    -</p>
    -<pre>
    -cd ~/allsky
    -website/install.sh</pre>
    -
    -<p>
    -The installation won't prompt for anything, and will display minimal output.
    -</p>
    -
    -<p>
    -After installation do the following:
    -<ul class="minimalPadding">
    -	<li>Update the <span class="fileName">config.sh</span> file
    -		via the WebUI's <span class="WebUIWebPage">Editor</span> page so Allsky knows which files
    -		to copy to the Website (e.g., startrails, timelapse, etc.).
    -	<li>Update the <span class="fileName">ftp-settings.sh</span> file
    -		via the WebUI's <span class="WebUIWebPage">Editor</span> page so Allsky knows
    -		where to copy the files to.
    -		See the instructions for
    -		<a allsky="true" href="/documentation/settings/allskyWebsite.html#ftp-settings">updating the <span class="fileName">ftp-settings.sh</span> file</a>.
    -</ul>
    -
    -<hr class="separator">
    -</details>
    -
    -
     
    -<h2>Install on a remote server</h2>
    -<details><summary></summary>
    +<h2>Install a remote Allsky Website</h2>
     <p>
     Most people have their Pi behind a firewall where it's not accessible on the Internet,
     so they install the Allsky Website on a different
     machine that <u>is</u> accessible on the Internet.
    +<!--
     <blockquote>
     If you want to make your Pi available on the Internet, see
     <a external="true" href="https://medium.com/swlh/host-a-raspberry-pi-web-server-on-the-internet-89786287db77">these instructions</a>.
     <br>
     <strong>Be careful if you do this - if done incorrectly your Pi may be insecure.</strong>
     </blockquote>
    +-->
     </p>
     <p>
     Prior to installing the Website on a remote server,
     make sure Allsky is working on your Pi, then do the following:
     <ol>
    -<li>On the remote server:
    -	<ul class="minimalPadding">
    -	<li>Create an <span class="fileName">allsky</span> directory to hold the Website.
    -		<strong>Keep track of where on your server you created the directory</strong>
    -		- you will need that information later when you tell Allsky where to upload the images.
    -	</ul>
    -<li>Go to the <a href="https://github.com/AllskyTeam/allsky-website">allsky-website GitHub page</a>.
    -<li>Click the <img allsky="true" src="Code.png" alt="Code" class="buttonIcon" loading="lazy">
    -	button, download the <span class="fileName">allsky-website-master</span> zip file and
    -	unzip it to a location on your PC, Pi, or Mac.
    -<li>Upload the <b>contents</b> of the <span class="fileName">allsky-website-master</span> directory
    -	(but not the directory itself) to the
    -	<span class="fileName">allsky</span> directory on the remote server.
    -<li>Do <b>not</b> configure the Website yet - you'll do that below.
    -<li>On the Pi:
    -	<ol class="minimalPadding">
    -	<li>Update the <span class="fileName">config.sh</span> file
    -		via the WebUI's <span class="WebUIWebPage">Editor</span> page so Allsky knows which files
    -		to upload (e.g., startrails, timelapse, etc.).
    -	<li>Update the <span class="fileName">ftp-settings.sh</span> file
    -		via the WebUI's <span class="WebUIWebPage">Editor</span> page so Allsky knows
    -		where on the remote server to upload the files to.
    -		See the instructions for
    -		<a allsky="true" href="../settings/allskyWebsite.html#ftp-settings">updating the <span class="fileName">ftp-settings.sh</span> file</a>.
    -		<blockquote>
    -			This step MUST be done before the next step since the next step
    -			tries to upload a test file to your remote server and it will fail
    -			if you haven't configured the settings properly.
    -		</blockquote>
    -	<li>Run <code>cd ~/allsky;  website/install.sh --remote</code>.
    -		This will upload a default configuration file to your server,
    -		leaving the master copy on the Pi.
    +	<li>On the remote server:
     		<ul>
    -		<li>If the upload fails, see the
    -			<a allsky="true" href="/documentation/troubleshooting/uploads.html">Troubleshooting uploads</a>
    -			page on how to debug the problem.
    -		<li>If you also have an Allsky Website on your Pi,
    -			the remote configuration file will be identical to the local one with the
    -			exception of the <span class="editorSetting">imageName</span> and
    -			<span class="editorSetting">onPi</span> settings,
    -			which will be configured for a remote Website.
    -			<br>
    -			If you want the remote configuration to differ from the local one
    -			(for example, to add a background image to the remote Website),
    -			edit the remote configuration file - see the next step.
    +			<li>Create an <span class="fileName">allsky</span>
    +				directory to hold the Website.
    +				<strong>Keep track of where on your server you created the directory</strong>
    +				- you will need that information later when you tell Allsky
    +				where to upload the images.
     		</ul>
    -	<li>Configure the remote Website:
    -		<ul class="minimalPadding">
    -			<li>In the WebUI, click on the <span class="WebUIWebPage">Editor</span> link.
    -			<li>In the drop-down at the bottom of the page, select
    -				<code class="noWrap">remote_configuration.json (remote Allsky Website).</code>
    -			<li>See
    -				<a allsky="true" href="/documentation/settings/allskyWebsite.html">this page</a>
    -				for details on the Website settings.
    -		</ul>
    -		<blockquote class="warning">
    -		Whenever you update the remote Website's configuration you <strong>must</strong>
    -		do so via WebUI (follow the first two steps above).
    -		Do NOT edit the configuration file directly on the remote server.
    -		</blockquote>
    -	</ol>
    -<li>Your remote server is now ready.
    -<li>Give your family and friends the URL to your Allsky Website so they can enjoy your images!
    +	<li>On the Pi:
    +		<ol type="A">
    +		<li>Copy ALL files and directories in
    +			<span class="fileName">~/allsky/html/allsky</span>
    +			to the <span class="fileName">allsky</span>
    +			directory on your remote Website.
    +			<br>Do this using whatever procedure you use to copy other files there.
    +		<li>Go to the <span class="settingsHeader">Websites and Remote Server Settings</span>
    +			section in the WebUI's <span class="WebUIWebPage">Allsky Settings</span> page.
    +			Update the settings in the
    +			<span class="subSettingsHeader">Remote Website</span> subsection.
    +			Make sure to enable <span class="WebUISetting">Use Remote Website</span>
    +			as well as enough other settings so Allsky can upload a file to the Website.
    +		<li>Run <code>cd ~/allsky;  ./remoteWebsiteInstall.sh</code>
    +			to upload a default configuration file to your server,
    +			leaving the master copy on the Pi.
    +			<ul>
    +			<li>If the upload fails, see the
    +				<a allsky="true" href="../troubleshooting/uploads.html">Troubleshooting uploads</a>
    +				page on how to debug the problem.
    +			<li>If you previously enabled the <u>local</u> Allsky Website,
    +				the remote configuration file will be identical to the local one with the
    +				exception of the <span class="editorSetting">imageName</span> setting
    +				which will be configured for the remote Website.
    +				<br>
    +				If you want the remote configuration to differ from the local one
    +				(for example, to add a background image to the remote Website),
    +				edit the remote configuration file - see the next step.
    +			</ul>
    +		<li>Configure the remote Website:
    +			<ul class="minimalPadding">
    +				<li>In the WebUI, click on the <span class="WebUIWebPage">Editor</span> link.
    +				<li>In the drop-down at the bottom of the page, select
    +					<code class="noWrap">remote_configuration.json (remote Allsky Website).</code>
    +				<li>See the
    +					<a allsky="true" href="/documentation/settings/allskyWebsite.html">
    +					Settings -> Allsky Website</a>
    +					page for details on the settings.
    +				<li>A copy of the remote Website configuration
    +					file will be uploaded to the server.
    +			</ul>
    +			<blockquote class="warning">
    +			Whenever you update the remote Website's configuration you <strong>must</strong>
    +			do so via WebUI following the steps above.
    +			Do NOT edit the configuration file directly on the remote server.
    +			</blockquote>
    +		</ol>
    +	<li>Your remote server is now ready.
    +	<li>Give your family and friends the URL to your Allsky Website so they can enjoy your images!
     </ol>
     
     
    -<h3>Remote Website Requirements</h3>
    +<h4>Remote Website Requirements</h4>
     The remote server needs to support the following:
     <ul class="minimalPadding">
     	<li>PHP version 7 or newer.
    @@ -212,32 +150,22 @@ <h3>Remote Website Requirements</h3>
     		to create thumbnails of the timelapse videos.
     		<blockquote>
     		Most hosting solutions don't support those commands for security reasons.
    -		In that case, set <span class="shSetting">TIMELAPSE_UPLOAD_THUMBNAIL</span> to "true"
    -		(and <span class="shSetting">TIMELAPSE_MINI_UPLOAD_THUMBNAIL</span> to "true"
    -		if you are creating mini timelapses) in <span class="fileName">config.sh</span>.
    +		In that case, make sure <span class="WebUISetting">Upload Timelapse Thumbnail</span>
    +		is enabled (and <span class="WebUISetting">Upload Mini-Timelapse Thumbnail</span>
    +		if you are creating mini timelapses).
     		Failure to set those variables will result in "No thumbnail" messages when
     		viewing videos.
     		Everything else will still work.
     		</blockquote>
     </ul>
    -<hr class="separator">
    -</details>
     
     
    -<h2>Post installation - local and remote</h2>
    +<h2>Post installation</h2>
     <p>
     Change
     <a allsky="true" href="/documentation/settings/allskyWebsite.html">Allsky Website Settings</a>
    -as desired so the Website looks and behaves like you want it to.
    -Changes to both local Websites and remote Websites are both done via the WebUI.
    -</p>
    -
    -<p>
    -If your prior version of the Allsky Website was in
    -<span class="fileName">/var/www/html/allsky</span>
    -note that there is only one configuration file in the new Website
    -(<span class="fileName">configuration.json</span>)
    -that replaces the two older files.
    +as desired so the Website looks and behaves like you want.
    +Changes to both local Websites and remote Websites are done via the WebUI.
     </p>
     
     <blockquote>
    @@ -248,7 +176,6 @@ <h2>Post installation - local and remote</h2>
     the lens, you'll need to change it in both files.
     </blockquote>
     
    -</details>
     
     </div><!-- Layout-main -->
     </div><!-- Layout -->
    diff --git a/html/documentation/installations/Code.png b/html/documentation/installations/Code.png
    deleted file mode 100644
    index 319da917d..000000000
    Binary files a/html/documentation/installations/Code.png and /dev/null differ
    diff --git a/html/documentation/installations/RemoteServer.html b/html/documentation/installations/RemoteServer.html
    new file mode 100644
    index 000000000..88a25e598
    --- /dev/null
    +++ b/html/documentation/installations/RemoteServer.html
    @@ -0,0 +1,81 @@
    +<!DOCTYPE html>
    +<html lang="en">
    +<head>
    +	<meta charset="utf-8">
    +	<meta http-equiv="X-UA-Compatible" content="IE=edge">
    +	<meta name="viewport" content="width=device-width, initial-scale=1">
    +	<script src="../js/documentation.js" type="application/javascript"></script>
    +	<link href="../css/light.css" rel="stylesheet">
    +	<link href="../bower_components/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
    +	<script src="../bower_components/jquery/dist/jquery.min.js"></script>
    +	<script src="../bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
    +	<style>
    +		#pageTitle::before {
    +			content: "Preparing a Remote Server";
    +		} 
    +	</style>
    +	<link href="../css/custom.css" rel="stylesheet">
    +	<link href="../css/documentation.css" rel="stylesheet">
    +	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
    +	<title>Preparing a Remote Server</title>
    +</head>
    +<body>
    +<div w3-include-html="/documentation/pageHeader.html" id="pageHeader"></div>
    +<div class="Layout">
    +<div class="Layout-sidebar" w3-include-html="/documentation/sidebar.html" id="sidebar"></div>
    +<div class="Layout-main markdown-body" id="mainContents">
    +
    +<p>
    +This page describes how to prepare a remote server
    +to accept uploads of the current Allsky image
    +as well as startrails, keograms, and timelapse videos.
    +</p>
    +<h4>Why use a remote server?</h4>
    +<p>
    +Since this server does NOT run the Allsky Website software you may wonder
    +why have a remote server.
    +Let's say you have a web page for an observatory that has a description of
    +your equipment as well as some astrophotographs you've taken.
    +If you also want to include the current allsky image as well as last night's timelapse,
    +you'd simply set up your observatory web server to accept pictures from Allsky,
    +then update Allsky to upload the pictures.
    +</p>
    +
    +<h4>Preparing the remote server</h4>
    +<p>
    +The exact commands you use will depend on your server and/or hosting solution,
    +but at a high level the steps are:
    +<ol>
    +	<li>Create the following directories on the server,
    +		making sure their names are lowercase and spelled exactly as below:
    +		<ul class="minimalpadding">
    +			<li><span class="fileName">allsky</span>
    +			<li><span class="fileName">allsky/videos</span>
    +			<li><span class="fileName">allsky/videos/thumbnails</span>
    +			<li><span class="fileName">allsky/startrails</span>
    +			<li><span class="fileName">allsky/startrails/thumbnails</span>
    +			<li><span class="fileName">allsky/keograms</span>
    +			<li><span class="fileName">allsky/keograms/thumbnails</span>
    +		</ul>
    +	<li>Go to the <span class="subSettingsHeader">Remote Server Settings</span> section
    +		of the WebUI's <span class="WebUIWebPage">Allsky Settings</span> page
    +		and fill in the settings.
    +		See the
    +		<a allsky="true" external="true" href="../settings/allsky.html#remoteServerSettings">
    +		Allsky Settings</a> page for a description of the settings.
    +</ol>
    +</p>
    +<p>
    +After enabling the <span class="WebUISetting">Use Remote Server</span> setting
    +a test file will be uploaded to the server to ensure it works.
    +If there are any problems, visit the
    +<a allsky="true" href="/documentation/troubleshooting/uploads.html">
    +Troubleshooting -&gt; Uploads</a> page for details on how to fix the problem.
    +
    +
    +</div><!-- Layout-main -->
    +</div><!-- Layout -->
    +</body>
    +</html>
    +<script> includeHTML(); </script>
    diff --git a/html/documentation/js/documentation.js b/html/documentation/js/documentation.js
    index c474a2876..85542886d 100644
    --- a/html/documentation/js/documentation.js
    +++ b/html/documentation/js/documentation.js
    @@ -1,165 +1,165 @@
    -// Modify URLs on the GitHub server so we can view pages there OR on a Pi.
    -// Also, allow files to be included since GitHub doesn't have PHP.
    -
    -var debug = false;
    -
    -// branch is updated during installation.
    -var branch = "master";
    -
    -// On the Pi, URLs will either begin with "/documentation" (which is a web
    -// alias to ~/allsky/documentation), or will be a relative path.
    -// Either way, use it as is.
    -
    -// On GitHub, all URLs must have "/documentation/" in them - either because they were defined
    -// that way in the html file, or we add them in this script.
    -var documentation_URL = "documentation";
    -var documentation_URL_full = "/" + documentation_URL + "/";
    -
    -var onGitHub;
    -
    -var git_hostname = "htmlpreview.github.io";
    -var preURL_href, preURL_src;			// What gets prepended to the desired URL.
    -
    -var git_preURL_href = "https://" + git_hostname + "/?";
    -var git_raw = "https://raw.githubusercontent.com/AllskyTeam/allsky/";
    -
    -if (debug) console.log("location.hostname=" + location.hostname + ", git_hostname=" + git_hostname);
    -if (location.hostname == git_hostname) {
    -	onGitHub = true;
    -
    -	// On GitHub, the URLs for anchors (href=) and images (src=) are different.
    -	// anchors need get_preURL_href prepended to them.
    -	// image URLs and files that are included don't need get_preURL_href.
    -	preURL_href = git_preURL_href + git_raw + branch + "/html";
    -	preURL_src = git_raw + branch + "/html";
    -} else {
    -	onGitHub = false;		// on a Pi
    -	preURL_href = "";
    -	preURL_src = "";
    -}
    -if (debug) console.log("preURL_href=" + preURL_href + ", preURL_src=" + preURL_src);
    -preURL_include = preURL_src;
    -
    -// Convert URL for all tags with an "allsky=true" attribute.
    -// The specified URL will never be a full URL, i.e., it'll start with "/" or a dir/file.
    -function convertURL() {
    -	allTags = document.getElementsByTagName("*");
    -	for (var i = 0; i < allTags.length; i++) {
    -		var elmnt = allTags[i];
    -
    -		if (elmnt.getAttribute("external")) {
    -			elmnt.innerHTML += " <i class='fa fa-external-link-alt fa-fw'></i>";
    -			if (debug) console.log("elmnt=", elmnt);
    -
    -			elmnt["target"] = "_blank";
    -			elmnt["title"] = "Opens in new tab/window";
    -		}
    -
    -		/*
    -			Search for elements with an "allsky" attribute which means
    -			we need to update the URL.
    -		*/
    -		if (! elmnt.getAttribute("allsky")) continue;	// "allsky" not defined - ignore tag
    -
    -		var url = null;
    -		var preURL;
    -
    -		// Most of the elements with "allsky" are href, so look for them first.
    -		var attribute = "href";
    -		url = elmnt.getAttribute(attribute);
    -		if (url) {
    -			preURL = preURL_href;
    -		} else {	// not "href", look for "src".
    -			attribute = "src";
    -			url = elmnt.getAttribute(attribute);
    -			if (url) {
    -				preURL = preURL_src;
    -			} else {
    -				console.log("---- Did not find 'href' or 'src' for " + elmnt);
    -				continue;
    -			}
    -		}
    -
    -		// See if the url starts with documentation_URL_full which is the root of the documentation.
    -		var isDocumentation = (url.indexOf(documentation_URL_full) === 0) ? true : false;
    -		if (debug) if (! isDocumentation) console.log("isDoc=" + isDocumentation + ", url=" + url);
    -		if (! isDocumentation) {
    -			// Get the directory of the current page.
    -			var dir = document.URL.substr(0,document.URL.lastIndexOf('/'))
    -			var d = dir.lastIndexOf('/');
    -			dir = dir.substr(d+1);
    -			if (debug) console.log("== dir=" + dir);
    -
    -			// Prepend the documentation string followed by the current directory
    -			// if we're not already in the documentation directory.
    -			if (url.substr(0,2) === "//") {
    -				// Why does htmlpreview start the URL with "//" ?
    -				url = "https:" + url;
    -			} else if (dir !== documentation_URL) {
    -				url = documentation_URL_full + dir + "/" + url;
    -			}
    -			if (debug) console.log("== new url=" + url);
    -		}
    -
    -		// Only prepend on GitHub if not already there.
    -		if (onGitHub && url.indexOf(preURL) < 0) {
    -			url = preURL + url;
    -			if (debug) console.log("== new url after adding preURL=" + url);
    -		} else if (debug) console.log("url.indexOf(preURL)=" + url.indexOf(preURL));
    -		// else on Pi so nothing to do since the URL is already correct.
    -
    -		elmnt[attribute] = url;
    -	}
    -}
    -
    -
    -// Include a file (e.g., header, footer, sidebar) in a page using Javascript.
    -function includeHTML(numCalls) {
    -	var t = typeof(numCalls)
    -	if (t == "undefined") numCalls = 1;
    -	if (debug) console.log("t=" + t + ", numCalls=" + numCalls);
    -
    -	/* Search all HTML elements looking for ones that specify a file should be included. */
    -	allTags = document.getElementsByTagName("*");
    -	for (var i = 0; i < allTags.length; i++) {
    -		var elmnt = allTags[i];
    -		/*search for elements with a certain atrribute:*/
    -		var file = elmnt.getAttribute("w3-include-html");
    -		if (file) {
    -			/* Make an HTTP request using the attribute value as the file name */
    -			var xhttp = new XMLHttpRequest();
    -			xhttp.onreadystatechange = function() {
    -				if (this.readyState == 4) {
    -					if (this.status == 200)
    -						elmnt.innerHTML = this.responseText;
    -					else if (this.status == 400 || this.status == 404)
    -						elmnt.innerHTML = this.status + ": Page not found.";
    -					/*
    -						Remove the attribute, and call this function once more
    -						to see if there are any new entries to process and to handle
    -						any other original entries.
    -					 */
    -					elmnt.removeAttribute("w3-include-html");
    -					includeHTML(numCalls + 1);
    -				}
    -			}
    -
    -			if (onGitHub) {
    -				var pre = preURL_include;
    -				if (file.substr(0,1) !== "/") {
    -					pre += "/";
    -				}
    -				file = pre + file;
    -			}  // else on Pi so nothing to do since the URL is already correct.
    -			xhttp.open("GET", file, true);
    -			xhttp.send();
    -
    -			/* Exit the function: */
    -			return;
    -		}
    -	}
    -
    -	// We only get here after a call to includeHTML() doesn't find any more files to include.
    -if (debug) console.log("AT END numCalls=" + numCalls);
    -	convertURL();
    -}
    +// Modify URLs on the GitHub server so we can view pages there OR on a Pi.
    +// Also, allow files to be included since GitHub doesn't have PHP.
    +
    +var debug = false;
    +
    +// branch is updated during installation.
    +var branch = "master";
    +
    +// On the Pi, URLs will either begin with "/documentation" (which is a web
    +// alias to ~/allsky/documentation), or will be a relative path.
    +// Either way, use it as is.
    +
    +// On GitHub, all URLs must have "/documentation/" in them - either because they were defined
    +// that way in the html file, or we add them in this script.
    +var documentation_URL = "documentation";
    +var documentation_URL_full = "/" + documentation_URL + "/";
    +
    +var onGitHub;
    +
    +var git_hostname = "htmlpreview.github.io";
    +var preURL_href, preURL_src;			// What gets prepended to the desired URL.
    +
    +var git_preURL_href = "https://" + git_hostname + "/?";
    +var git_raw = "https://raw.githubusercontent.com/AllskyTeam/allsky/";
    +
    +if (debug) console.log("location.hostname=" + location.hostname + ", git_hostname=" + git_hostname);
    +if (location.hostname == git_hostname) {
    +	onGitHub = true;
    +
    +	// On GitHub, the URLs for anchors (href=) and images (src=) are different.
    +	// anchors need get_preURL_href prepended to them.
    +	// image URLs and files that are included don't need get_preURL_href.
    +	preURL_href = git_preURL_href + git_raw + branch + "/html";
    +	preURL_src = git_raw + branch + "/html";
    +} else {
    +	onGitHub = false;		// on a Pi
    +	preURL_href = "";
    +	preURL_src = "";
    +}
    +if (debug) console.log("preURL_href=" + preURL_href + ", preURL_src=" + preURL_src);
    +preURL_include = preURL_src;
    +
    +// Convert URL for all tags with an "allsky=true" attribute.
    +// The specified URL will never be a full URL, i.e., it'll start with "/" or a dir/file.
    +function convertURL() {
    +	allTags = document.getElementsByTagName("*");
    +	for (var i = 0; i < allTags.length; i++) {
    +		var elmnt = allTags[i];
    +
    +		if (elmnt.getAttribute("external")) {
    +			elmnt.innerHTML += " <i class='fa fa-external-link-alt fa_external'></i>";
    +			if (debug) console.log("elmnt=", elmnt);
    +
    +			elmnt["target"] = "_blank";
    +			elmnt["title"] = "Opens in new tab/window";
    +		}
    +
    +		/*
    +			Search for elements with an "allsky" attribute which means
    +			we need to update the URL.
    +		*/
    +		if (! elmnt.getAttribute("allsky")) continue;	// "allsky" not defined - ignore tag
    +
    +		var url = null;
    +		var preURL;
    +
    +		// Most of the elements with "allsky" are href, so look for them first.
    +		var attribute = "href";
    +		url = elmnt.getAttribute(attribute);
    +		if (url) {
    +			preURL = preURL_href;
    +		} else {	// not "href", look for "src".
    +			attribute = "src";
    +			url = elmnt.getAttribute(attribute);
    +			if (url) {
    +				preURL = preURL_src;
    +			} else {
    +				console.log("---- Did not find 'href' or 'src' for " + elmnt);
    +				continue;
    +			}
    +		}
    +
    +		// See if the url starts with documentation_URL_full which is the root of the documentation.
    +		var isDocumentation = (url.indexOf(documentation_URL_full) === 0) ? true : false;
    +		if (debug) if (! isDocumentation) console.log("isDoc=" + isDocumentation + ", url=" + url);
    +		if (! isDocumentation) {
    +			// Get the directory of the current page.
    +			var dir = document.URL.substr(0,document.URL.lastIndexOf('/'))
    +			var d = dir.lastIndexOf('/');
    +			dir = dir.substr(d+1);
    +			if (debug) console.log("== dir=" + dir);
    +
    +			// Prepend the documentation string followed by the current directory
    +			// if we're not already in the documentation directory.
    +			if (url.substr(0,2) === "//") {
    +				// Why does htmlpreview start the URL with "//" ?
    +				url = "https:" + url;
    +			} else if (dir !== documentation_URL) {
    +				url = documentation_URL_full + dir + "/" + url;
    +			}
    +			if (debug) console.log("== new url=" + url);
    +		}
    +
    +		// Only prepend on GitHub if not already there.
    +		if (onGitHub && url.indexOf(preURL) < 0) {
    +			url = preURL + url;
    +			if (debug) console.log("== new url after adding preURL=" + url);
    +		} else if (debug) console.log("url.indexOf(preURL)=" + url.indexOf(preURL));
    +		// else on Pi so nothing to do since the URL is already correct.
    +
    +		elmnt[attribute] = url;
    +	}
    +}
    +
    +
    +// Include a file (e.g., header, footer, sidebar) in a page using Javascript.
    +function includeHTML(numCalls) {
    +	var t = typeof(numCalls)
    +	if (t == "undefined") numCalls = 1;
    +	if (debug) console.log("t=" + t + ", numCalls=" + numCalls);
    +
    +	/* Search all HTML elements looking for ones that specify a file should be included. */
    +	allTags = document.getElementsByTagName("*");
    +	for (var i = 0; i < allTags.length; i++) {
    +		var elmnt = allTags[i];
    +		/*search for elements with a certain atrribute:*/
    +		var file = elmnt.getAttribute("w3-include-html");
    +		if (file) {
    +			/* Make an HTTP request using the attribute value as the file name */
    +			var xhttp = new XMLHttpRequest();
    +			xhttp.onreadystatechange = function() {
    +				if (this.readyState == 4) {
    +					if (this.status == 200)
    +						elmnt.innerHTML = this.responseText;
    +					else if (this.status == 400 || this.status == 404)
    +						elmnt.innerHTML = this.status + ": Page not found.";
    +					/*
    +						Remove the attribute, and call this function once more
    +						to see if there are any new entries to process and to handle
    +						any other original entries.
    +					 */
    +					elmnt.removeAttribute("w3-include-html");
    +					includeHTML(numCalls + 1);
    +				}
    +			}
    +
    +			if (onGitHub) {
    +				var pre = preURL_include;
    +				if (file.substr(0,1) !== "/") {
    +					pre += "/";
    +				}
    +				file = pre + file;
    +			}  // else on Pi so nothing to do since the URL is already correct.
    +			xhttp.open("GET", file, true);
    +			xhttp.send();
    +
    +			/* Exit the function: */
    +			return;
    +		}
    +	}
    +
    +	// We only get here after a call to includeHTML() doesn't find any more files to include.
    +if (debug) console.log("AT END numCalls=" + numCalls);
    +	convertURL();
    +}
    diff --git a/html/documentation/knownIssues.html b/html/documentation/knownIssues.html
    index 1ef7a580f..ab8700c42 100644
    --- a/html/documentation/knownIssues.html
    +++ b/html/documentation/knownIssues.html
    @@ -40,16 +40,6 @@
     
     <h4>Image Capture</h4>
     <ul>
    -	<li><span class="ki">Known Issue</span>:
    -		Some ZWO cameras can produce <strong>ASI_ERROR_TIMEOUT</strong> messages
    -		and fail to take pictures.
    -
    -		<br><span class="wa">Workaround</span>:
    -		See <a allsky="true" href="/documentation/troubleshooting/ZWOCameras.html">this page</a>.
    -
    -		<br><span class="fp">Future Plans</span>:
    -		We hope that when we port the RPi auto-exposure algorithm to ZWO it will fix this.
    -
     	<li class="morePadding"><span class="lim">Limitation</span>:
     		RPi cameras don't support sensor temperature.
     
    @@ -76,6 +66,20 @@ <h4>Image Capture</h4>
     		<br><span class="fp">Future Plans</span>:
     		Will fix in the next major release for users running the Bullseye and Bookworm operating systems.
     
    +	<li class="morePadding"><span class="lim">Limitation</span> (ZWO only):
    +		When using the default <span class="WebUISetting">ZWO Exposure Method</span>
    +		of <span class="WebUIValue">Snapshot</span>, <span class="WebUISetting">Auto Gain</span>
    +		and <span class="WebUISetting">Auto White Balance</span> do not not work.
    +
    +		<br><span class="wa">Workaround</span>:
    +		<span class="WebUISetting">Auto Gain</span> should normally not be used since
    +		it conflicts with the Allsky <span class="WebUISetting">Auto Exposure</span> algorithm.
    +		<br>If you need <span class="WebUISetting">Auto White Balance</span>, try a different
    +		<span class="WebUISetting">ZWO Exposure Method</span>.
    +
    +		<br><span class="fp">Future Plans</span>:
    +		None.
    +
     <!--
     	<li><span class="ki">Known Issue</span>:
     
    @@ -134,15 +138,40 @@ <h4>Modules</h4>
     <h4>Other</h4>
     <ul>
     	<li><span class="lim">Limitation</span>:
    -		Can't specify how images are sorted in the WebUI's
    -		<span class="WebUILink">Images</span> page
    -		and in the Allsky Website.
    +		If you have three or more cameras connected to a single Pi
    +		(very rare except while testing) including at least one RPi and one ZWO,
    +		if you switch from the current camera type to the other type which has at least
    +		two cameras, which of those cameras is used is undefined.
    +		<br>
    +		For example, assume you have one ZWO camera and two RPi cameras and
    +		the ZWO camera is the current one and you switch to
    +		the RPi <span class="WebUISetting">Camera Type</span> in the WebUI.
    +		Which RPi camera becomes current is undefined, and you'll likely see several
    +		errors about missing or empty fields.
    +
    +		<br><span class="wa">Workaround</span>: Click on the
    +		<span class="WebUILink">Allsky Settings</span> link to refresh the page.
    +
    +		<br><span class="fp">Future Plans</span>:
    +		The <span class="WebUISetting">Camera Type</span> and
    +		<span class="WebUISetting">Camera Model</span>
    +		settings will be merged into one setting that contains both items.
    +		You will then be able to select exactly which camera to change to,
    +		which will eliminate this problem.
    +</ul>
    +
    +<!--
    +<h4>Other</h4>
    +<ul>
    +	<li><span class="lim">Limitation</span>:
    +		xxxx
     
     		<br><span class="wa">Workaround</span>: None
     
     		<br><span class="fp">Future Plans</span>:
    -		Will add options to specify ascending (a-z) or descending (z-a) order.
    +		xxxx
     </ul>
    +-->
     
     
     </div><!-- Layout-main -->
    diff --git a/html/documentation/miscellaneous/ASI178-example-1.png b/html/documentation/miscellaneous/ASI178-example-1.png
    new file mode 100644
    index 000000000..6c06de11c
    Binary files /dev/null and b/html/documentation/miscellaneous/ASI178-example-1.png differ
    diff --git a/html/documentation/miscellaneous/ASI178-example-2.png b/html/documentation/miscellaneous/ASI178-example-2.png
    new file mode 100644
    index 000000000..739236b5b
    Binary files /dev/null and b/html/documentation/miscellaneous/ASI178-example-2.png differ
    diff --git a/html/documentation/miscellaneous/AllskyMap.html b/html/documentation/miscellaneous/AllskyMap.html
    index 25a541086..5d75dd10d 100644
    --- a/html/documentation/miscellaneous/AllskyMap.html
    +++ b/html/documentation/miscellaneous/AllskyMap.html
    @@ -14,6 +14,7 @@
     			content: "Allsky Map";
     		}
     	</style>
    +	<link href="../css/custom.css" rel="stylesheet">
     	<link href="../css/documentation.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
     	<script src="../js/all.min.js" type="application/javascript"></script>
    @@ -27,48 +28,45 @@
     
     <h2>Overview</h2>
     <p>
    -Starting with version 0.8.3.3 of Allsky,
    -you can automatically have your allsky camera(s) added to the global
    -<a external="true" href="https://www.thomasjacquin.com/allsky-map/" target="_blank">Allsky Map</a>.
    -This map shows the location of all known allsky cameras as well as basic information about them.
    -Click on the image below to go to the Allsky Map.
    +The
    +<a external="true" href="https://www.thomasjacquin.com/allsky-map/" target="_blank">Allsky Map</a>
    +shows the location and basic information of all known allsky cameras.
    +Clicking on a pin displays information about the camera and if the owner has
    +set their image URL, you'll see their last image as well;
    +clicking on the image takes you to their Allsky website.
     </p>
     
     <img allsky="true" src="allsky-map-with-pins.png" class="imgCenter imgBorderDark" title="Example Allsky Map" loading="lazy">
     
    +
    +<h3>How to add your camera</h3>
     <p>
    -To have your camera added, enable the <span class="WebUISetting">Show On Map</span>
    -setting in the <b>Allsky Map Settings</b> section of the WebUI.
    -If you don't see that section your version of the WebUI
    -(and probably Allsky itself) is too old and needs to be updated.
    +To add your camera to the map, enable the <span class="WebUISetting">Show On Map</span>
    +setting in the <span class="settingsHeader">Allsky Map Settings</span> section of the WebUI.
     If your camera is already on the map you can remove it by turning the setting off.
    -You can also update your information by changing any of the map-related fields.
    +You can also update your information by changing any of the map-related settings.
     </p>
     <p>
    -Once you've enabled the <span class="WebUISetting">Show On Map</span> setting,
    -your map information will be automatically sent to the map server every other day
    -so it can distinguish current data from old, expired data.
    +Once you've enabled <span class="WebUISetting">Show On Map</span>,
    +your map information will be automatically sent to the map server whenever you change it,
    +as well as every other day so it can distinguish current data from old, expired data.
     </p>
     <p>
     All these changes take effect immediately - no human intervention required!
     </p>
    -<p>
    -Clicking on a pin on the map displays information about the camera and if the owner has
    -set their image URL, you'll see the last image as well.
    -And if the owner set their website URL you can click on the image to go to their Allsky website.
    -</p>
     
     <p>
    -See the <b>"Allsky Settings" Page</b> section of the
    -<a allsky="true" external="true" href="/documentation/settings/allsky.html" target="_blank">Allsky Settings</a>
    -page for a description of each map-related setting in the WebUI.
    +See the 
    +<a allsky="true" external="true" href="/documentation/settings/allsky.html#AllskyMap"
    +	target="_blank">Allsky Settings</a>
    +documentation page for a description of the map-related settings.
     </p>
     
     
    -<h2>My website isn't available on the Internet</h2>
    +<h3>My Website isn't available on the Internet</h3>
     You can still have a pin of your camera's location appear on the map - just
     leave the <span class="WebUISetting">Website URL</span> and
    -<span class="WebUISetting">Image URL</span> fields blank.
    +<span class="WebUISetting">Image URL</span> fields blank in the WebUI.
     
     </div><!-- Layout-main -->
     </div><!-- Layout -->
    diff --git a/html/documentation/miscellaneous/Case1a.png b/html/documentation/miscellaneous/Case1a.png
    new file mode 100644
    index 000000000..4b1bbbc4e
    Binary files /dev/null and b/html/documentation/miscellaneous/Case1a.png differ
    diff --git a/html/documentation/miscellaneous/Case1b.png b/html/documentation/miscellaneous/Case1b.png
    new file mode 100644
    index 000000000..361d8b775
    Binary files /dev/null and b/html/documentation/miscellaneous/Case1b.png differ
    diff --git a/html/documentation/miscellaneous/Case2a.png b/html/documentation/miscellaneous/Case2a.png
    new file mode 100644
    index 000000000..e6f8f294d
    Binary files /dev/null and b/html/documentation/miscellaneous/Case2a.png differ
    diff --git a/html/documentation/miscellaneous/Case2b.png b/html/documentation/miscellaneous/Case2b.png
    new file mode 100644
    index 000000000..62c2e1ed1
    Binary files /dev/null and b/html/documentation/miscellaneous/Case2b.png differ
    diff --git a/html/documentation/miscellaneous/Case3-offset.png b/html/documentation/miscellaneous/Case3-offset.png
    new file mode 100644
    index 000000000..66d72daa7
    Binary files /dev/null and b/html/documentation/miscellaneous/Case3-offset.png differ
    diff --git a/html/documentation/miscellaneous/FAQ.html b/html/documentation/miscellaneous/FAQ.html
    index 3394a2671..713e973c3 100644
    --- a/html/documentation/miscellaneous/FAQ.html
    +++ b/html/documentation/miscellaneous/FAQ.html
    @@ -16,6 +16,7 @@
     	</style>
         <link href="../css/documentation.css" rel="stylesheet">
         <link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js"></script>
     	<title>Allsky FAQ</title>
     </head>
     <body>
    @@ -24,133 +25,6 @@
     <div class="Layout-sidebar" w3-include-html="/documentation/sidebar.html" id="sidebar"></div>
     <div class="Layout-main markdown-body" id="mainContents">
     
    -<h2>How do I copy files to/from the Pi?</h2>
    -<details><summary></summary>
    -<p>
    -If the file is a text file and it fits on one screen,
    -for instance, in an editor, you can simply highlight the text and copy to the Pi's clipboard,
    -then paste it into a file on your PC or Mac.
    -</p>
    -<p>
    -If you have a large file or a binary file, you can mount the Pi's filesystem
    -onto your PC or Mac by using SAMBA.
    -<p>Copy the lines below to a file on your Pi named <code>installSamba.sh</code>
    -(this is easiest if you are viewing this page in a browser on your Pi).
    -</p>
    -<pre>
    -#!/bin/bash
    -
    -# Install SAMBA to enable network access to another device.
    -# Base idea from StackExchange ( https://bit.ly/3Qqzbnp )
    -
    -if [[ -z ${LOGNAME} ]]; then
    -	echo "Unknown LOGNAME; cannot continue;" &gt;&amp;2
    -	exit 1
    -fi
    -source "${ALLSKY_HOME}/variables.sh"	|| exit 1
    -CAP="${LOGNAME:0:1}"
    -CAP="${CAP^^}${LOGNAME:1}"
    -SHARE_NAME="${LOGNAME}_home"
    -
    -echo -e "${YELLOW}"
    -echo "*************"
    -echo "This script will install SAMBA which lets remote devices mount your Pi as a network drive."
    -echo "The '${HOME}' directory on the Pi will appear as '${SHARE_NAME}' on remote devices."
    -echo "You can then copy files to and from the Pi as you would from any other drive."
    -echo
    -echo -n "Press any key to continue: "; read x
    -echo "${NC}"
    -
    -# Install SAMBA 
    -sudo apt install samba -y				|| exit 1
    -
    -# Add the user to SAMBA and prompt for their SAMBA password.
    -echo -e "${YELLOW}"
    -echo "*************"
    -echo "You will be prompted for a SAMBA password which remote machines will use to"
    -echo "map to your Pi's drive."
    -echo "This is a different password than ${LOGNAME}'s password or the root password,"
    -echo "although you may elect to make them the same."
    -echo
    -echo "If this is your first time installing SAMBA and you are prompted for a current password,"
    -echo "press 'Enter'."
    -echo "*************"
    -echo "${NC}"
    -sudo smbpasswd -a ${LOGNAME}			|| exit 1
    -
    -WORKGROUP="WORKGROUP"
    -CONFIG_FILE="/etc/samba/smb.conf"
    -echo -e "${GREEN}..... Configuring SAMBA.${NC}"
    -
    -sudo mv -f ${CONFIG_FILE} ${CONFIG_FILE}.bak
    -
    -sudo tee ${CONFIG_FILE} &gt; /dev/null &lt;&lt;EOF
    -### Config File ###
    -
    -[global]
    -workgroup = ${WORKGROUP}
    -server role = standalone server
    -obey pam restrictions = no
    -map to guest = never
    -
    -client min protocol = SMB2
    -client max protocol = SMB3
    -vfs objects = catia fruit streams_xattr
    -fruit:metadata = stream
    -fruit:model = RackMac
    -fruit:posix_rename = yes
    -fruit:veto_appledouble = no
    -fruit:wipe_intentionally_left_blank_rfork = yes
    -fruit:delete_empty_adfiles = yes
    -security = user
    -encrypt passwords = yes
    -
    -# Optional logging.  Is very verbose.
    -# log file = /var/log/samba/log.%m
    -# max log size = 1000
    -# logging = file
    -
    -# The directories you want accessible by other devices.
    -# Each one's name must be surrounded by [].
    -
    -[${SHARE_NAME}]
    -comment = ${CAP} home directory
    -path = ${HOME}
    -browseable = yes
    -read only = no
    -create mask = 0664
    -directory mask = 0775
    -
    -### end Config ###
    -EOF
    -
    -echo -e "${GREEN}..... Restarting SAMBA.${NC}"
    -sudo /etc/init.d/smbd restart
    -
    -echo -e "${YELLOW}"
    -echo "*************"
    -echo "You can now mount '${SHARE_NAME}' on your remote device using"
    -echo "workgroup '${WORKGROUP}' and login name '${LOGNAME}'."
    -echo "If you don't know how to do that, see your remote device's operating system documentation."
    -echo "*************"
    -echo -e "${NC}"
    -</pre>
    -</p>
    -<p>
    -Now execute:
    -<pre>
    -chmod 755 installSamba.sh
    -./installSamba.sh
    -</pre>
    -
    -and follow the prompts.
    -On a PC you should then see something like this in Windows File Explorer:
    -<p><img src="Pi_network_drive.png" title="Pi network drive" class="imgBorder"></p>
    -Mount it as you would any other network drive.
    -Remember to use the SAMBA password you entered during installation.
    -</details>
    -
    -
     <h2>After starting Allsky, all I get is "Allsky software is starting up"</h2>
     <details><summary></summary>
     <p>
    @@ -163,17 +37,18 @@ <h2>After starting Allsky, all I get is "Allsky software is starting up"</h2>
     </p>
     <p>
     If you are using <b>auto</b> exposure/gain, the starting values are what you specified as the manual values,
    -and it may take several exposures for the software to home in on the best exposure.
    +and it may take several exposures for the software to find the best exposure.
     While it's doing that, you'll see the "Allsky software is starting up" message.  This is normal.
    -If, however the message remains after several minutes follow the instructions
    +However, if the message remains after several minutes follow the instructions
     <a allsky="true" href="/documentation/troublehsooting/reportingIssues.html">here</a>
     and submit the log file.
     </p>
     <p>
    -You can also temporarily set <span class="shSetting">REMOVE_BAD_IMAGES</span> to "false" in
    -<span class="fileName">~/allsky/config/config.sh</span>
    +You can also temporarily set the
    +<span class="WebUISetting">Remove Bad Images Thresholds</span> to
    +<span class="WebUIValue">0</span>
     to see what the incorrectly exposed images look like - this
    -might give you an idea as to the problem.
    +might give you an idea of the problem.
     </p>
     </details>
     
    @@ -205,106 +80,154 @@ <h2>Why is there is a long delay between pictures?</h2>
     </details>
     
     
    -<h2>How do I reduce wear on my SD card?</h2>
    +<h2 id="SDwear">How do I reduce wear on my SD card?</h2>
     <details><summary></summary>
     <p>
    -SDcards have a limited number of writes they can handle before they wear out.
    -Although this is usually a very large number you may wish nonetheless to
    -minimize writes to the SDcard.
    -The bet way to do this is by making Allsky's <span class="fileName">allsky/tmp</span>
    -directory a memory-based filesystem,
    -i.e., instead of residing on the SDcard it resides in RAM memory.
    +SD cards have a limited number of writes they can handle before they wear out.
    +Although this is usually a very large number you may want to
    +minimize writes to the SD card
    +by moving the <span class="fileName">~/allsky/tmp</span>
    +directory from the SD card into memory.
    +</p>
    +<p>
    +During Allsky installation you were prompted to do this -
    +if you did you can ignore this FAQ item.
    +</p>
     
     <blockquote>
    -During Allsky installation you were prompted to create
    -<span class="fileName">tmp</span> as a memory-based filesystem.
    -If you did that you can ignore this tip.
    +If you have a Pi 5 or newer you can also replace the SD card with an NVMe SSD disk
    +which is much faster, more reliable, and allows significantly more space than an SD card
    +(but costs more).
    +To do this you'll need to add a board to your Pi
    +(called an <strong>NVMe HAT</strong>) as well as an NVMe disk.
    +Search for those terms on the Internet to see your options.
     </blockquote>
     
    -Note the following:
    +Before putting <span class="fileName">~/allsky/tmp</span> into memory
    +note the following:
     <ul>
     <li>In order to do this you'll need enough free RAM memory,
     	so this may not work well with systems with very limited memory, for example, 512 MB.
    -<li>The contents of the <span class="fileName">tmp</span>
    -	directory will be erased every time you reboot your Pi.
    -	This should be ok since it's only used for temporary log files and to hold images
    -	as they come out of the camera,
    -	before they are stored in <span class="fileName">allsky/images/DATE</span>.
    +<li>The <span class="fileName">~/allsky/tmp</span> directory and its contents
    +	will be erased every time you reboot your Pi.
    +	This is ok since that directory is only used for temporary files (hence the same)
    +	that are invalid after a reboot.
     </ul>
     <p>
    -It's simple to create a memory-based filesystem:
    +then execute:
     <pre>
    -cd ~/allsky
    -./install.sh --function check_tmp
    +allsky-config  recheck_tmp
     </pre>
     
    -It's suggested you accept the defaults.
    +You should accept the defaults.
     </details>
     
     
     <h2>How do I focus my allsky camera?</h2>
     <details><summary></summary>
    -<blockquote>
    -If you have a camera with auto-focus like the RPi Module 3,
    -see the camera documentation for how to focus it.
    -</blockquote>
    +<h3>Manual-focus cameras</h3>
    +<p>
    +Almost all lenses have a focus ring that you turn to change the focus.
    +If you lens does NOT have a focus ring or some other focusing mechanism,
    +you can skip this page.
     <p>
    -Try get your camera at least roughly focused during the day.
    +Try to get your camera at least roughly focused during the day.
     It'll be easier to see the focus ring on your lens,
     and exposure duration will be much shorter so you'll get more instant feedback.
     Focusing on slow-moving clouds works well.
     </p>
     <p>
    -Enable the <span class="WebUISetting">Show Focus Metric</span>
    -setting in the WebUI's <span class="WebUIWebPage">Allsky Settings</span> page.
    -A focus number will appear on your images - the higher the number, the better focus you have.
    -Note that the number can change depending on the brightness,
    -so focus when the brightness isn't changing.
    +Make the following temporary changes in the WebUI:
    +<ul class="minimalPadding topPadding">
    +	<li>Turn off the <span class="WebUISetting">Save</span>
    +		setting for the part of the day you are focusing (daytime or nighttime).
    +	<li>Set the corresonding <span class="WebUISetting">Delay</span> to
    +		<span class="WebUIValue">500</span> ms (1/2 second).
    +	<li>If focusing during the day, also set the <span class="WebUISetting">Max Auto-Exposure</span>
    +		to <span class="WebUIValue">500</span> ms.
    +		<br>
    +		These changes will give a short time between images and the WebUI's
    +		<span class="WebUILink">Live View</span> will automatically refresh the screen every second.
    +	<li>If you are using the <span class="WebUIValue">module</span>
    +		<span class="WebUISetting">Overlay Method</span>,
    +		enable the <span class="WebUISetting">Show Focus Metric</span>,
    +		and in the <span class="WebUILink">Overlay Editor</span> page add the <strong>${FOCUS}</strong>
    +		variable to the image anywhere you want.
    +		Use a large font to make it easier to see.
    +</ul>
    +</p>
    +<p>
    +Most lenses have a screw to lock the focus ring - if your's does, loosen the screw.
    +A focus number will appear on the images - the higher the number, the better focus.
    +The number can change depending on the brightness, so focus when the brightness isn't changing.
    +Look at the image and the focus number then turn the lens' focus ring.
    +If the image and focus number get worse, turn the ring the other way.
    +At some point the image will get worse - that means you've past the best focus,
    +so turn the focus ring in very small increments the other direction.
     <p>
    -When done focusing, disable the <span class="WebUISetting">Show Focus Metric</span>
    -setting since it's no longer needed.
    -While this method may be "good enough", the most accurate way to focus is
    -at night using stars.
    +This method is often "good enough" but the most accurate (and slowest) way to focus is at night using stars.
     Zoom in on the image and look for stars that are bright but not saturated.
     Keep focusing until the stars are as small as possible.
     </p>
    +<p>
    +When done focusing tighten the focus lock screw (be careful not to move the focus ring)
    +and revert all settings back to their original values.
    +</p>
    +
    +<h3>Auto-focus cameras</h3>
    +<p>
    +If you have a camera with an auto-focus lens like the RPi Module 3, see the
    +<a external="true" href="https://www.raspberrypi.com/documentation/computers/camera_software.html#autofocus-mode">camera documentation</a>
    +for a description of focus-related settings.
    +You'll use the <span class="WebUIValue">--lens-position</span> setting
    +to determine where the best focus is.
    +Run the following (which puts the lens at infinity),
    +then as needed, increase the lens position in small increments until you
    +find the best focus:
    +<pre>
    +libcamera-still --timeout 1 -shutter 5000 --lens-position 0.0
    +</pre>
    +You will likely need to adjust the shutter speed to get a good exposure.
    +</p>
    +<p>
    +Once the camera is focused we suggest <strong>disabling</strong> auto focus
    +since it adds several seconds to each daytime image while it's finding focus,
    +and adds minutes to nighttime images.
    +To do so, add <span class="WebUIValue">--lens-position X</span>
    +to the <span class="WebUISetting">Extra Parameters</span> setting in the WebUI,
    +replacing <span class="WebUISetting">X</span> with the lens position of best focus.
    +</p>
     </details>
     
     
    -<h2>The <span class="fileName">/var/log/allsky.log</span> file is gone.  How do I get it back?</h2>
    +<h2>How do I change the icons on the Allsky Website?</h2>
     <details><summary></summary>
     <p>
    -<ul>
    -	<li>Try restarting the software: <code>sudo systemctl restart allsky</code>.
    -	<li>If that doesn't help, restart the software that controls the log files:
    -		<code>sudo systemctl restart syslog</code>.
    -	<li>If that doesn't help, reboot the Pi.
    -	<li>If that doesn't help, wait until tomorrow - sometimes the log file
    -		mysteriously reappears after midnight.
    -		Note this is NOT an Allsky problem since it also happens with other services.
    -</ul>
    +The icons on the left side of an Allsky Website page and what happens when you click one are controlled by the
    +<a external="true" href="../settings/allskyWebsite.html#leftSidebar">
    +	<span class="editorSetting">leftSidebar</span> setting</a>.
    +The icon itself is specified via the <span class="editorSetting">icon</span> sub-setting.
    +See the <a external="true" href="https://fontawesome.com/search?m=free&o=r">Font Awesome</a> page
    +(version 6.2.1) for other choices.
    +Note that not all icons work so you'll need to try them.
     </p>
     </details>
     
     
    -<h2>Pro-tip: install <code>gh</code> on your Pi so you can easily collaborate using Github Gists</h2>
    +<h2>The <span class="fileName">/var/log/allsky.log</span> file is gone.  How do I get it back?</h2>
     <details><summary></summary>
     <p>
    -Click <a href="https://cli.github.com/">here</a>
    -for the full details or download the latest release from
    -<a href="https://github.com/cli/cli/releases/latest">here</a>.
    -Once you have the Command Line Interface (CLI) installed,
    -you can easily upload a script you wrote with something like:
    -<pre>
    -gh gist create -d "a tool to find alien spaceships" < my_awesome_allsky_script.py
    -</pre>
    -
    -or share logs using something like
    -<pre>
    -journalctl --since 05:00 -u allsky | gh gist create
    -gh gist create < log.txt
    -</pre>
    -
    +Do the following in order, stopping when the log file reappears:
    +<ol>
    +	<li>Try restarting the software: <code>sudo systemctl restart allsky</code>.
    +	<li>Restart the software that controls the log files:
    +		<code>sudo systemctl restart syslog</code>.
    +	<li>Reboot the Pi.
    +	<li>Wait until tomorrow - sometimes the log file
    +		mysteriously reappears after midnight.
    +		Note this is NOT an Allsky problem since it also happens with other services.
    +</ol>
    +</p>
     </details>
     
     
    diff --git a/html/documentation/miscellaneous/allsky-map-with-pins.png b/html/documentation/miscellaneous/allsky-map-with-pins.png
    index 24183aa51..74c790d15 100644
    Binary files a/html/documentation/miscellaneous/allsky-map-with-pins.png and b/html/documentation/miscellaneous/allsky-map-with-pins.png differ
    diff --git a/html/documentation/miscellaneous/cleaningWebsite.html b/html/documentation/miscellaneous/cleaningWebsite.html
    index 274ba1067..32ba6389d 100644
    --- a/html/documentation/miscellaneous/cleaningWebsite.html
    +++ b/html/documentation/miscellaneous/cleaningWebsite.html
    @@ -24,33 +24,25 @@
     <div class="Layout-sidebar" w3-include-html="/documentation/sidebar.html" id="sidebar"></div>
     <div class="Layout-main markdown-body" id="mainContents">
     
    -<h2>How to clean up old Allsky files from a Website on your Pi</h2>
    +<h2>Removing old Allsky files from a Website on your Pi</h2>
     <p>
    -You can easily limit the number of days of images stored on your local Website by
    -using the WebUI to edit the
    -<span class="shSetting">WEB_DAYS_TO_KEEP</span> setting in
    -<span class="fileName">config.sh</span> with the number of days' data you want to keep.
    +You can easily limit the number of days of images stored on your local Website via
    +the <span class="WebUISetting">Days To Keep on Pi Website</span>
    +setting in the WebUI with the number of days' data you want to keep.
     Older files will be removed every morning.
     </p>
     
    -<h2>How to clean up old Allsky files from a remote Website</h2>
    -<p>
    -<blockquote>
    -The following instructions assume your startrails, keograms,
    -and timelapse files are stored in the directories specified in
    -<span class="fileName">ftp-settings.sh</span>.
    -</blockquote>
    -</p>
    +<h2>Removing old Allsky files from a remote Website</h2>
     <p>
     Because remote web sites vary, the instructions below are generic and it's
     assumed you know how to perform the steps.
     
    -<ul>
    -<li>Connect to the web site.
    +<ol>
    +<li>Connect to the remote Website server.
     <li>For each of the <span class="fileName">startrails</span>,
     	<span class="fileName">keograms</span>,
     	and <span class="fileName">videos</span> directories do the following:
    -	<ul>
    +	<ul class="minimalPadding">
     	<li>Enter the directory.
     	<li>Search for all *.jpg, *.png, or *.mp4 files older than your threshold,
     		for example, older than one month.
    @@ -58,7 +50,7 @@ <h2>How to clean up old Allsky files from a remote Website</h2>
     		<b>Do NOT remove the <span class="fileName">index.php</span> files!</b>
     	<li>Enter the <span class="fileName">thumbnails</span> sub-directory
     		and delete files older than your threshold.
    -</ul>
    +</ol>
     
     
     </div><!-- Layout-main -->
    diff --git a/html/documentation/miscellaneous/nomenclature.html b/html/documentation/miscellaneous/nomenclature.html
    index e70f69ac8..a84e93222 100644
    --- a/html/documentation/miscellaneous/nomenclature.html
    +++ b/html/documentation/miscellaneous/nomenclature.html
    @@ -16,6 +16,7 @@
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
     	<title>Nomenclature</title>
     </head>
     <body>
    @@ -24,70 +25,86 @@
     <div class="Layout-sidebar" w3-include-html="/documentation/sidebar.html" id="sidebar"></div>
     <div class="Layout-main markdown-body" id="mainContents">
     
    -<h2>Allsky Suite</h2>
    -The AllSky suite of software consists of two GitHub packages:
    +<h2>Allsky Software</h2>
    +The GitHub <a href="https://github.com/AllskyTeam/allsky" external="true"><code>allsky</code></a>
    +repository consists of two main parts which are both
    +included when installing Allsky.
     <ol>
    -<li><a href="https://github.com/AllskyTeam/allsky"><code>allsky</code></a>
    -is the main program that captures and saves images.
    -This also includes the Web User Interface (<b>WebUI</b>)
    -used to administer AllSky and parts of your Pi.
    -In addition to taking pictures, you can also:
    -<ul>
    -	<li>Display the most recent image, called a <b>Liveview</b>.
    -	<li>View all saved prior images, timelapse, keograms, and startrails.
    -	<li>Configure Allsky settings via a web form and a built-in color text editor.
    -	<li>See the status of the LAN and WLAN (Wireless LAN).
    -	<li>Configure Wi-Fi.
    -	<li>Change the WebUI login name and password.
    -	<li>View information about the Pi such as amount of free disk space, CPU temperature, etc.
    -	<li>Optionally view user-generated data such as ambient temperature or fan speed.
    -	<li>View Allsky Documentation, of which this page is part of.
    -</ul>
    +	<li><strong>Allsky</strong>
    +		is the main program that captures and saves images
    +		and includes the Web User Interface (<b>WebUI</b>)
    +		used to administer Allsky and parts of your Pi.
    +		You can also:
    +		<ul class="minimalPadding topPadding">
    +			<li>Display the most recent image, called a <b>Liveview</b>.
    +			<li>View all saved prior images, timelapses, keograms, and startrails.
    +			<li>Configure Allsky settings via the WebUI.
    +			<li>See the status of the LAN and WLAN (Wireless LAN).
    +			<li>Configure Wi-Fi.
    +			<li>View information about the Pi such as amount of free disk space,
    +				CPU temperature, etc.
    +			<li>Optionally view user-generated data such as ambient temperature or fan speed.
    +			<li>View Allsky Documentation, of which this page is part of.
    +		</ul>
     
    -<li><a href="https://github.com/AllskyTeam/allsky-website"><code>allsky-website</code></a>
    -is an optional simple web interface that displays:
    -<ul>
    -	<li>The most recent image.
    -	<li>An optional constellation overlay on the image.
    -	<li>Saved startrails, keograms, and/or timelapse videos.
    -	<li>Optionally the most recent "mini" timelapse video.
    -	<li>Information about your camera (location, owner, Pi version, camera type, etc.).
    -	<li>Optional information about your settings.
    -</ul>
    +	<li><strong>Allsky Website</strong> is a simple web interface that
    +		can run on your Pi and/or a remote server and displays:
    +		<ul class="minimalPadding topPadding">
    +			<li>The most recent image.
    +			<li>An optional constellation overlay on the image.
    +			<li>Saved startrails, keograms, and/or timelapse videos.
    +			<li>Optionally the most recent "mini" timelapse video.
    +			<li>Information about your camera like location, owner,
    +				Pi version, camera type, etc.
    +			<li>Optional information about your settings.
    +		</ul>
    +</ol>
     <p>
    -The <b>Allsky Website</b> can run on your Pi and/or one or more remote computers.
    +The GitHub
    +<a href="https://github.com/AllskyTeam/allsky-modules" external="true">
    +<code>user-modules</code></a>
    +repository contains additional, user-developed
    +modules that can be used with Allsky as explained on the
    +<a allsky="true" external="true" href="../modules/modules.html">Modules page</a>.
     </p>
    -</ol>
     
     <h2>Terms</h2>
     <ul>
    -<li>When a page says "AllSky Software" or just "software" it's referring to
    -	the main Allsky software that's running on your Pi from the
    -	<span class="fileName">~/allsky</span> directory.
    -<li>The <code>allsky-website</code> package is usually referred to as the
    -<b>Allsky Website</b> or just the <b>Website</b> (with a capital W).
    -<li>The <span class="fileName">config.sh</span> file holds a lot of the Allsky settings
    -and must be edited via the <span class="WebUIWebPage">Editor</span> link in the WebUI,
    -NOT by manually editting it.
    -<li>The <span class="fileName">ftp-settings.sh</span> file holds the upload-related settings and
    -must also be edited using the <span class="WebUIWebPage">Editor</span> page.
    +	<li>The <b>WebUI</b> is the Web User Interface used to administer Allsky.
    +	<li>When a page says "Allsky Software" or just "software" it's referring to
    +		the main Allsky software that's running on your Pi and resides in the
    +		<span class="fileName">~/allsky</span> directory.
    +	<li>A <b>server</b>, as in "remote server", is a computer that often sits
    +		in a datacenter and has a dedicated purpose such as hosting web pages or returning
    +		search results like Google.
    +	<li>The term <b>Allsky Website</b> is often shortened to
    +		<b>Website</b> - always with a capital <b>W</b>.
    +	<li>A <b>local</b> Allsky Website runs on the same Pi that Allsky runs on.
    +		<br>A <b>remote</b> Website runs somewhere else.
    +		Allsky doesn't care if the remote machine is a true "server" or
    +		simply a 10-year old PC sitting under your desk.
    +		The Allsky documentation considers both of them a "remote server".
     </ul>
     
     
     <h2>Documentation Conventions</h2>
    -Commands that are to be executed look like this: <code>systemctl start allsky</code>.
    -When multiple commands are to be executed they are usually displayed like this:
    +<ul>
    +	<li>Commands that are to be executed on the Pi
    +		look like this: <code>systemctl start allsky</code>.
    +		When multiple commands are to be executed they are usually displayed like this
    +		and can typically be copied from the documenation and pasted into a Pi terminal window:
     <pre>
     systemctl start allsky
     date
     </pre>
     
    -File names are shown as: <span class="fileName">config.sh</span>.
    -<br>
    -Menu items on the WebUI are shown as: <span class="WebUIWebPage">Editor</span>.
    -<br>
    -Settings in the WebUI appear as: <span class="WebUISetting">Debug Level</span>.
    -
    +	<li>File and directory names are shown as:
    +		<span class="fileName">configuration.json</span>.
    +	<li>Links on the WebUI to its various pages are shown as:
    +		<span class="WebUILink">Editor</span>.
    +	<li>Settings in the WebUI appear as: <span class="WebUISetting">Debug Level</span>.
    +		Settings' values in the WebUI appear as: <span class="WebUIValue">3</span>.
    +</ul>
     
     </div><!-- Layout-main -->
     </div><!-- Layout -->
    diff --git a/html/documentation/miscellaneous/pickingHardware.html b/html/documentation/miscellaneous/pickingHardware.html
    index 433b805e4..9f3687757 100644
    --- a/html/documentation/miscellaneous/pickingHardware.html
    +++ b/html/documentation/miscellaneous/pickingHardware.html
    @@ -11,10 +11,11 @@
     	<script src="../bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
     	<style>
     		#pageTitle::before {
    -			content: "Picking a Pi, an allsky camera, and lens";
    +			content: "Picking a Pi, a camera, and lens";
     		} 
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
    +	<link href="../css/custom.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
     	<title>Picking Hardware</title>
     </head>
    @@ -27,34 +28,51 @@
     <h2>Raspberry Pi choices</h2>
     <details><summary></summary>
     <p>
    -Like picking a camera, picking the model of Pi may come down to your budget and availability.
    +A <b>Pi 5 with 8 GB memory</b> is the best choice <em>if you can afford it</em>.
    +It handles everything Allsky currently does and has enough memory and CPU power for the future.
    +You'll notice the speed increase while producing
    +keograms, startrails, and timelapse videos as well as during installation and upgrades.
    +Taking pictures doesn't require much power so you likely won't notice a difference there.
    +If you use the Pi 5 for non-Allsky work you'll likely notice the speed increase as well.
     </p>
     <p>
    -Ignoring those constraints, the ideal Pi to get is the Pi 4 with 4 GB memory.
    -It handles everything Allsky currently does and has enough memory and CPU power for the future.
    -The next best Pi to get is the Pi 4 with 2 GB memory.
    +The next best Pi to get is the Pi 4 with 4 GB memory;
    +if that's still too expensive get a Pi 4 with 2 GB memory.
     </p>
    +<blockquote>
    +Note that in general, the faster a Pi is, the more heat it produces.
    +If your allsky camera's case is not ventilated you <em>may</em> run into problems.
    +</blockquote>
     <p>
    -If the Pi 4's above are too expensive, a Pi 3B+ with 2 GB will also work but will take longer to
    -create timelapse videos, and will limit how often you can create mini-timelapse videos.
    -
    -The 1 GB versions of the Pi 3B+ and 4 will work but require adding more swap space
    -which will be used more often, slowing things down and putting more wear on your SD card.
    +If the options above are too expensive, a <b>Pi 3B+ with 2 GB memory</b>
    +will also work but will take longer to create timelapse videos,
    +and will limit how often you can create mini-timelapse videos.
    +The 1 GB versions of the Pi 3B+ and Pi 4 will work but require adding more swap space,
    +slowing things down and putting more wear on your SD card.
     </p>
     <p>
    -Do NOT get the Pi Zero - its extremely slow CPU (only 1) and 512 MB memory will cause problems.
    -You almost for sure won't be able to do timelapse videos.
    -The Pi Zero 2 which has a faster CPU than the Pi Zero but still only has 512 MB memory
    -should only be considered if cost is the ONLY concern,
    -as you may or may not be able to do timelapse videos.
    +<strong>Do NOT get the Pi Zero</strong> - its extremely slow CPU
    +and minimal memory will cause problems.
    +You likely won't be able to create timelapse videos.
    +The <strong>Pi Zero 2</strong> has a faster CPU than the Pi Zero but still has limited memory and
    +should only be considered if cost is the ONLY concern.
    +You may or may not be able to do timelapse videos.
     Future versions of Allsky will likely require more processing power and memory so the
     Pi Zero 2 may not work in the future.
     </p>
     <blockquote class="warning">
    -We have not found any Pi clones except Le Potato to be compatible with Allsky.
    -The Le Potato clone works with a ZWO ASI120 camera;
    -no other cameras, including any RPi cameras, were tested, so buyer beware.
    +We have not found any Pi clones except Le Potato to be compatible with Allsky,
    +and it works only with a ZWO ASI120 camera (which we don't recommend).
     </blockquote>
    +
    +<h4>SD cards</h4>
    +<p>
    +We suggest getting <em>at least</em> a 128 GB SD card.
    +They are fairly inexpensive so if you can afford a 256 GB card, get it
    +- it will let you store more images and videos.
    +Unless you are copying lot of files, the speed of the SD card won't make
    +much of a difference.
    +</p>
     </details>
     
     <h2>Cameras</h2>
    @@ -68,47 +86,45 @@ <h2>Cameras</h2>
     </p>
     Some things to consider:
     <ul>
    -<li>If you need to buy a camera and will only use it as an allsky camera,
    -	the RPi HQ camera is hard to beat.
    -<li>ZWO's ASI120MM (1280x960 resolution) is inexpensive but
    -	a lot of people complain about its noise and hot pixels and have replaced theirs.
    -	The color version is significantly less sensitive.
    -	We recommend staying away from this camera.
    +<li>The <b>RPi HQ</b> is hard to beat unless you plan to also use the camera for
    +	astrophotography.
    +<li><strong>Stay away from the ZWO ASI120 camera</strong>.
    +	Although it is inexpensive it often produces a lot of errors and many
    +	people complain about its noise and hot pixels so have replaced theirs.
     <li>The choice of mono versus color is a personal one.
     	Mono cameras are usually more sensitive so require shorter exposures or less gain,
     	but many people prefer color for its more "natural" look, especially during the day.
    -<li>Larger sensors (for example the RPi HQ and ASI178MC @ 3096x2080) require more memory
    -	to produce timelapses, so you may need to follow
    -	<a allsky="true" href="/documentation/troubleshooting/timelapse.html">these suggestions</a>
    +<li>Higher-resolution sensors (for example the RPi HQ @ 4056x3040 and ASI178MC @ 3096x2080)
    +	require more memory to produce timelapses, so you may need to follow
    +	<a allsky="true" external="true" href="../troubleshooting/timelapse.html">these suggestions</a>
     	to get timelapse working.
    -<li>For allsky use, an extremely high resolution camera usually isn't needed since the lens and
    -	dome cause some amount of distortion that limit the effective resolution.
    -	We suggest staying away from these cameras.
    +<li>For allsky use, an extremely high resolution camera
    +	(e.g., higher than the RPi HQ) probably isn't useful since the lens and
    +	dome cause some distortion that limits the effective resolution.
     <li>Increasing sensor temperature will always increase noise and hot pixels,
     	sometimes to the point of making the image unusable,
     	so many people put a fan in their allsky enclosure to pull in cooler outside air.
    +	This can also increase the life of the camera and Pi.
     <li>Different cameras have different dynamic ranges
     	(the difference between the brightest and darkest parts).
     	Increasing a camera's gain will produce brighter, noisier images with less dynamic range.
    -	Consult the camera specs to learn more about the trade-off between gain and dynamic range,
    -	eg. <a href="https://astronomy-imaging-camera.com/product/asi482mc">ASI482MC</a>,
    -	<a href="https://astronomy-imaging-camera.com/product/asi290mm">ASI290MM</a>
    -	<a href="https://astronomy-imaging-camera.com/product/asi120mc">ASI120MC</a>,
    -	<a href="https://astronomy-imaging-camera.com/product/asi178mc">ASI178MC</a>.
    +	Consult the camera specs to learn more about the trade-off between gain and dynamic range.
     <li>Some RPi and RPi-compatible cameras support auto focus.
    -	That feature sounds really useful but those cameras have some limitations:
    -	<ul>
    -	<li>Auto focus at night takes a long time since the camera has to take several long-exposure
    -		images.
    -	<li>The lenses that come with these cameras typically produce only a 120 degree field of view.
    +	That feature sounds great but has some major limitations:
    +	<ul class="minimalPadding">
    +	<li>You will almost always disable auto focus at night - it takes a long time 
    +		since the camera has to take several long-exposure images to find the correct focus.
    +	<li>The lenses that come with these cameras typically produce at most
    +		a 120 degree field of view.
     		Many allsky lenses produce about 180 degrees.
     		A narrower field of view may not matter if you have a lot of trees and/or buildings
     		blocking the view, however.
    +	<li>Some people with auto focus lenses focus during the day and may refocus if the
    +		temperature changes drastically.
     	</ul>
    -	Some people with auto focus lenses focus during the day and may refocus if the
    -	temperature changes drastically.
     <li>The "T7C" camera is an OEM version of the ASI120MC-mini.
    -	We <a allsky="true" href="/documentation/troubleshooting/T7cameras.html">do not recommend</a>
    +	We <a allsky="true" external="true"
    +		href="/documentation/troubleshooting/T7cameras.html">do not recommend</a>
     	it.
     </ul>
     </details>
    @@ -117,114 +133,174 @@ <h2>Cameras</h2>
     <h2>Lenses</h2>
     <details><summary></summary>
     <p>
    -Although picking a camera is fairly easy since Allsky only supports ZWO and RPi cameras,
    -picking a lens is more difficult.
    -The lens and camera sensor together determine what kind of image you'll see:
    -<ul>
    -	<li>a round image
    -	<li>a round image with the top and bottom cut off
    -	<li>a full rectangle image
    -</ul>
    +Lenses have a few attributes you'll want to be aware of,
    +and together with the camera sensor determine what kind of image you'll see.
     </p>
     <p>
    -The lower the focal length of the lens the wider of a field of view (FOV) it produces.
    -Most people like a wide or very wide FOV for an allsky camera, for example, 180 degrees,
    +Generally, the lower a lens' <strong>focal length</strong> is
    +(expressed in mm, like 2.1 mm), the wider a <strong>field of view</strong> (FOV) it produces.
    +Most people like a very wide FOV such as 180 degrees
     so they can image as much of the sky as possible.
    -Lenses that produce very wide FOV are called <strong>fisheye</strong> lenses.
    +Lenses that produce very wide FOVs are usually called <strong>fisheye</strong> lenses.
    +If your allsky camera is surrounded by lots of trees or buildings and you
    +don't want them in your images, use a <em>higher</em> focal length lens.
    +</p>
    +<p>
    +Typical focal lengths used in allsky lens are 1.5 mm - 2.5 mm.
    +Some allsky lens can vary their focal length so are called "zoom" lenses.
    +These are more expensive and produce lesser-quality images so are seldom used.
    +Typical FOVs are 120 degrees - 180 degrees.
    +</p>
    +<p>
    +The <strong>aperture</strong> of a lens determines how much light it passes through.
    +<em>Lower</em> numbers let more light through but in general produce fuzzier images
    +that you may or may not notice.
    +Typical apertures (prepended by <code>f</code>) used in allsky lens are f 1.4 - f 2.8.
     </p>
     <p>
     Lenses are round so produce round images.
    -<blockquote>
    -If you want to see this for yourself,
    -in a bright area take your lens and hold it a little above a flat surface.
    -Move the lens up and down to produce an in-focus image.
    -What shape is the image?
    -How large is the image?
    -</blockquote>
    -Most cameras have rectangular sensors that are wider than taller.
    +The size of the image it produces on the sensor, measured by its diameter in mm,
    +is called its <strong>image circle</strong>.
    +</p>
    +
    +<h3>Images produced</h3>
    +<details><summary></summary>
    +<p>
    +Most allsky cameras have rectangular sensors that are wider than they are tall.
    +The images below show a sensor (black with dark gray border)
    +and image circles produced by various fictional lenses (white with stars and a yellow borders).
    +</p>
    +<p>
    +Depending on the physical size of the sensor and the size of the image circle,
    +you'll get different results, as shown below.
    +There is no "right" or "wrong" combination - it's a personal preference.
    +
     <ol>
    -	<li>If the image created by the lens fits completely on the camera's sensor you'll see
    -		a round image with black outside of it.
    -	<li>If most, but not all of the lens' image fits on the sensor you'll see a round image
    -		with the top and bottom cut off.
    +	<li>An image circle that is <em>much</em> smaller than the sensor.
    +		This uncommon scenario produces a very small sky image.
    +		<br><img src="Case1a.png" class="centerImg" width="50%">
    +	<li>An image circle whose height is the same as the sensor.
    +		This shows the whole the sky image
    +		and leaves ample room to overlay text and images.
    +		<br>It's rare to have the sensor height and imager circle be <em>identical</em>
    +		as shown below.
    +		<br><img src="Case1b.png" class="centerImg" width="50%">
    +	<li>An image circle whose height is bigger than the sensor
    +		but whose width is the same as the sensor.
    +		This cuts off part of the sky image but minimizes the black border,
    +		yet still allows for some space to add text without overwriting the sky image.
    +		Most very wide FOV sky images include some landscape and/or buildings
    +		around the edges so depending on how much of the sky image is cut off,
    +		this combination can be beneficial.
    +		<br>It's rare to have the sensor width and imager circle be <em>identical</em>
    +		as shown below.
    +		<br><img src="Case2a.png" class="centerImg" width="50%">
     	<li>If the lens' image fills the whole sensor you'll see what appears to be
    -		a "normal" image with no black.
    -	</ul>
    +		a "normal" image with no black border.
    +		This combination is usually the result of a very small camera sensor.
    +		<br><img src="Case2b.png" class="centerImg" width="50%">
     </ol>
     </p>
    +</details>
    +
    +<h3>What kind of image will I get?</h3>
    +<details><summary></summary>
     <p>
    -To determine which of the three above you'll get you need to know the physical size of the
    -camera's sensor and the size of the image circle produced by the lens.
    -The sensor size is easy to get - almost all web pages that mention the sensor
    -list its size.
    -Not all lens web pages list the image circle size though - in fact,
    -one of the lenses below didn't list the image size.
    +To determine which of the examples above you'll get you need to know the physical size of the
    +camera's sensor and the size of the lens' image circle.
    +<blockquote class="warning">
    +Don't confuse a sensor's <u>physical size</u> with its <u>pixel size</u>.
    +The pixel size only determines how fine of detail you'll see in a sky image
    +- it has nothing to do with anything else on this page.
    +</blockquote>
    +
    +<br>
    +Most camera web pages list the sensor's width and height but you'll normally need
    +to calculate the sensor's <strong>diagonal</strong> size using a little math:
    +<pre>
    +Take the square root of: ( (sensor_width * sensor_width) + (sensor_height * sensor_height) )
    +
    +##### For the RPi HQ camera:
    +The square root of: ( (6.3 mm * 6.3 mm) + (4.7 mm * 4.7 mm) )  =  <strong>7.86 mm</strong>
    +</pre>
    +Almost all calculators have a "square root" function.
     </p>
     <p>
    -DISCLAIMER: this list is not meant to recommend anything;
    -it's only for your information to illustrate a point.
    +Not all lens web pages list the image circle size - in fact,
    +one of the lenses below didn't list it.
    +You may need to contact the seller or search on the Web.
     </p>
     <p>
    -The tables below list some common allsky cameras and some lenses.
    -Below the tables is an example of the results that different lenses on the same camera produce.
    +To see what you'll get, compare the sizes:
    +<ul class="minimalPadding">
    +	<li>If the image circle size is <em>less than or equal to</em> the sensor <u>height</u>,
    +		the full image will fit on the sensor (examples 1 and 2 above).
    +	<li>If the image circle size is <em>less than</em> the sensor <u>diagonal</u>,
    +		the top and bottom of the sky image will be cut off (example 3).
    +	<li>If the image circle size is <em>greater than</em> the sensor <u>diagonal</u>,
    +		the sky image will completely fill the sensor (example 4).
    +</ul>
    +</p>
    +
    +<p>
    +Some common allsky cameras and lenses are shown below
    +followed by an example of the results that different lenses produce using the same camera.
     </p>
     
     <table>
     <thead>
     	<tr>
    -		<th colspan="5" class="tableHeader">Common allsky cameras</th>
    +		<th colspan="6" class="tableHeader">Common allsky cameras</th>
     	</tr>
     	<tr>
     		<th>Camera</th>
     		<th>Sensor</th>
    -		<th>Resolution (w x h)</th>
    -		<th>Width</th>
    -		<th>Height</th>
    +		<th>Sensor Width</th>
    +		<th>Sensor Height</th>
    +		<th>Sensor Diagonal</th>
    +		<th>Resolution (w&nbsp;x&nbsp;h)</th>
     	</tr>
     </thead>
     <tbody>
     	<tr>
     		<td>RPi HQ</td>
     		<td>Sony IMX477</td>
    -		<td>4056 x 3040</td>
     		<td>6.3 mm</td>
     		<td>4.7 mm</td>
    -	</tr>
    -	<tr>
    -		<td>ZWO ASI120</td>
    -		<td>AR0130CS</td>
    -		<td>1280 x 960</td>
    -		<td>4.8 mm</td>
    -		<td>3.6 mm</td>
    +		<td>7.86 mm</td>
    +		<td>4056 x 3040</td>
     	</tr>
     	<tr>
     		<td>ZWO ASI178</td>
     		<td>Sony IMX178</td>
    -		<td>3096 x 2080</td>
     		<td>7.4 mm</td>
     		<td>5.0 mm</td>
    +		<td>8.93 mm</td>
    +		<td>3096 x 2080</td>
     	</tr>
     	<tr>
     		<td>ZWO ASI224</td>
     		<td>Sony IMX224</td>
    -		<td>1403 x 976</td>
     		<td>4.9 mm</td>
     		<td>3.7 mm</td>
    +		<td>6.14 mm</td>
    +		<td>1403 x 976</td>
     	</tr>
     	<tr>
     		<td>ZWO ASI290</td>
     		<td>Sony IMX290</td>
    -		<td>1936 x 1096</td>
     		<td>5.6 mm</td>
     		<td>3.2 mm</td>
    +		<td>6.45 mm</td>
    +		<td>1936 x 1096</td>
     	</tr>
     </tbody>
     </table>
    -
    +<br><br>
     <table>
     <thead>
     	<tr>
    -		<th colspan="6" class="tableHeader">Example allsky lenses</th>
    +		<th colspan="5" class="tableHeader">Example allsky lenses</th>
     	</tr>
     	<tr>
     		<th>Lens</th>
    @@ -232,25 +308,22 @@ <h2>Lenses</h2>
     		<th>Image circle</th>
     		<th>Aperture</th>
     		<th>FOV</th>
    -		<th>Resoution</th>
     	</tr>
     </thead>
     <tbody>
    -	<tr>
    -		<td><a href="https://aico-lens.com/product/1-4mm-c-fisheye-lens-acf12fm014ircmm/">cnAICO ACF12FM014IRCMM</a></td>
    -		<td>1.4 mm</td>
    -		<td>4.59 mm</td>
    -		<td>F 1.4</td>
    -		<td>182 deg</td>
    -		<td>5 Mpx</td>
    -	</tr>
     	<tr>
     		<td><a href="https://aico-lens.com/product/1-55mm-cs-mount-fisheye-lens-acf12f0155irmm/">cnAICO ACF12F0155IRMM</a></td>
     		<td>1.5 mm</td>
     		<td>4.6 mm</td>
     		<td>F 2.0</td>
     		<td>185 deg</td>
    -		<td>5 Mpx</td>
    +	</tr>
    +	<tr>
    +		<td><a href="https://aico-lens.com/product/2-5mm-8mp-cs-mount-fisheye-lens-actcs25ir8mpf/">cnAICO ACTCS25IR8MPF</a></td>
    +		<td>2.5 mm</td>
    +		<td>6.4 mm</td>
    +		<td>F 1.6</td>
    +		<td>190 deg</td>
     	</tr>
     	<tr>
     		<td><a href="https://aico-lens.com/product/2-1mm-focal-length-wide-angle-fov-160-degree-cs-mount-cctv-lens-accf021163mp/">cnAICO ACCF021163MP</a></td>
    @@ -258,56 +331,60 @@ <h2>Lenses</h2>
     		<td>?? </td>
     		<td>F 1.6</td>
     		<td>160 deg</td>
    -		<td>3 Mpx</td>
     	</tr>
     	<tr>
    -		<td><a href="https://aico-lens.com/product/2-5mm-8mp-cs-mount-fisheye-lens-actcs25ir8mpf/">cnAICO ACTCS25IR8MPF</a></td>
    -		<td>2.5 mm</td>
    -		<td>6.4 mm</td>
    -		<td>F 1.6</td>
    -		<td>190 deg</td>
    -		<td>8 Mpx</td>
    +		<td><a href="https://aico-lens.com/product/1-4mm-c-fisheye-lens-acf12fm014ircmm/">cnAICO ACF12FM014IRCMM</a></td>
    +		<td>1.4 mm</td>
    +		<td>4.59 mm</td>
    +		<td>F 1.4</td>
    +		<td>182 deg</td>
     	</tr>
     </tbody>
     </table>
     
     <p>
    -As an example, let's use the common ZWO ASI178 camera with a couple different lenses.
    +As an example, let's use the ZWO ASI178 camera with a couple different lenses.
     <ol>
    -	<li>Lens ACF12F0155IRMM: 1.5 mm focal length.
    +	<li><b>Lens # 1, ACF12F0155IRMM</b>: 1.5 mm focal length.
     		<br>
    -		The image circle of the lens (4.6 mm) is <strong>smaller</strong> than the height of the sensor (5.0 mm),
    -		so you'll see the full circle image produced by the lens with a little bit of black above and below,
    -		and a fair amount of black on the sides.
    -		You may want to crop the sides of the image to remove some of the black
    +		The lens' image circle (4.6 mm) is <strong>smaller</strong>
    +		than the height of the sensor (5.0 mm) so the whole image fits on the sensor.
    +		You may want to crop the sides of the image to remove some of the black border
     		and save disk space.
    -	<li>Lens ACTCS25IR8MPF: 2.5 mm focal length.
    +		<br><img src="ASI178-example-1.png" title="ZWO ASI178 example - image fits"
    +			class="centerImg" width="25%">
    +	<li><b>Lens # 2, ACTCS25IR8MPF</b>: 2.5 mm focal length.
     		<br>
    -		The image circle of the lens (6.4 mm) is <strong>larger</strong> than the height of the sensor (5.0 mm)
    +		The lens' image circle (6.4 mm) is <strong>larger</strong>
    +		than the height of the sensor (5.0 mm)
     		but smaller than the width of the sensor (7.4 mm),
    -		so you'll see a circle with the top and bottom cut off a fair amount,
    -		and some amount of black on the sides.
    +		so you'll see a circle with the top and bottom cut off
    +		and some black on the sides.
    +		<br><img src="ASI178-example-2.png" title="ZWO ASI178 example - image cut off"
    +			class="centerImg" width="25%">
     </ol>
     </p>
     
     <p>
    -As you can see, with the same camera you get quite different results depending on the lens used.
     In the examples above, if you used a camera with a much larger sensor you would see the full
     cicular image even with the 2.5 mm lens.
     On the other hand, if you used a much <strong>smaller</strong> sensor, like the ASI224,
     you would see a slightly cut-off circle with the 1.5 mm lens
     and would see a rectangular image with no black using the 2.5 mm lens.
     </p>
    +</details>
    +
    +<h3>Image offset</h3>
     <p>
    -Also notice that the camera's <strong>resolution</strong> did not come into play.
    -It only determines how much <em>detail</em> you can see,
    -not how much of the image you can see.
    -</p>
    -<p>
    -If you have a lot of trees or buildings blocking the horizon and you want to
    -minimize how much of them you see,
    -get a lens with a higher focal length and hence a narrower FOV.
    +It's not uncommon for the sky image to be off center relative to the sensor, as seen below.
    +The small yellow circle is the center of the sky image and the small dark square is the center
    +of the sensor.
    +<br><img src="Case3-offset.png" title="Lens offset" class="centerImg" width="50%">
    +<br>Most allsky lenses are inexpensive and don't provide "perfect" results.
    +This is not a problem since you can easily crop the image to center it.
     </p>
    +
    +
     </details>
     
     
    diff --git a/html/documentation/modules/Module Manager.png b/html/documentation/modules/Module Manager.png
    deleted file mode 100755
    index 86a69bd2a..000000000
    Binary files a/html/documentation/modules/Module Manager.png and /dev/null differ
    diff --git a/html/documentation/modules/mask-raw.png b/html/documentation/modules/mask-raw.png
    index b8cf07674..bc795d718 100755
    Binary files a/html/documentation/modules/mask-raw.png and b/html/documentation/modules/mask-raw.png differ
    diff --git a/html/documentation/modules/module-manager-settings.png b/html/documentation/modules/module-manager-settings.png
    index 4c818c745..d4bf2cfaf 100755
    Binary files a/html/documentation/modules/module-manager-settings.png and b/html/documentation/modules/module-manager-settings.png differ
    diff --git a/html/documentation/modules/modules.html b/html/documentation/modules/modules.html
    index 2a5976b72..2eda55ba3 100644
    --- a/html/documentation/modules/modules.html
    +++ b/html/documentation/modules/modules.html
    @@ -2,2246 +2,2907 @@
     <html lang="en">
     
     <head>
    -    <meta charset="utf-8">
    -    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    -    <meta name="viewport" content="width=device-width, initial-scale=1">
    -    <script src="../js/documentation.js" type="application/javascript"></script>
    -    <link href="../css/light.css" rel="stylesheet">
    -    <link href="../bower_components/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
    -    <script src="../bower_components/jquery/dist/jquery.min.js"></script>
    -    <script src="../bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
    -    <style>
    -        #pageTitle::before {
    -            content: "Allsky Modules";
    -        }
    -    </style>
    -    <link href="../css/documentation.css" rel="stylesheet">
    -    <link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    -    <script src="../js/all.min.js" type="application/javascript"></script>
    -    <title>AllSky Modules</title>
    +	<meta charset="utf-8">
    +	<meta http-equiv="X-UA-Compatible" content="IE=edge">
    +	<meta name="viewport" content="width=device-width, initial-scale=1">
    +	<script src="../js/documentation.js" type="application/javascript"></script>
    +	<link href="../css/light.css" rel="stylesheet">
    +	<link href="../bower_components/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
    +	<script src="../bower_components/jquery/dist/jquery.min.js"></script>
    +	<script src="../bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
    +	<style>
    +		#pageTitle::before {
    +			content: "Modules";
    +		}
    +	</style>
    +	<link href="../css/documentation.css" rel="stylesheet">
    +	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
    +	<title>Modules</title>
     </head>
     
     <body>
    -    <div w3-include-html="/documentation/pageHeader.html" id="pageHeader"></div>
    -    <div class="Layout">
    -        <div class="Layout-sidebar" w3-include-html="/documentation/sidebar.html" id="sidebar"></div>
    -        <div class="Layout-main markdown-body" id="mainContents">
    -
    -
    -            <h2>What are modules?</h2>
    -            <div>
    -                <p>Modules provide functionality that can be used to enhance the images created by AllSky,
    -                    modules can also provide functionality that does not affect the images but more on that
    -                    later. The Module Manager allows you to control which modules are run and when they are run.
    -                </p>
    -                <p>There are two types of modules</p>
    -                <ul>
    -                    <li><strong>System Modules</strong> - These are developed and maintained by the AllSky team.
    -                        These modules are installed with the main AllSky installer</li>
    -                    <li><strong>User Modules</strong> - These are developed and maintained by people outside of
    -                        the AllSky development team. These modules are installed via a separate installer, See
    -                        LINK. These modules can be deleted if no longer required</li>
    -                </ul>
    -
    -                <blockquote>
    -                    <strong>User Modules</strong> User modules must only be installed from the official AllSky
    -                    repositories. Installing modules from other source could be dangerous and is not encouraged.
    -                </blockquote>
    -
    -                <blockquote>
    -                    <strong>Experimental Modules</strong> When installed some modules will display a warning
    -                    that they are experimental. Before using any of these modules you should ensure that you are
    -                    proficient in analysing Linux Logs files. They should be considered unstable and as such may
    -                    'break' your AllSky installation. In the
    -					<span class="managerName">Module Manager</span> settings you can enable/disable
    -                    experimental modules
    -                </blockquote>
    -
    -                <blockquote>
    -                    <strong>Hardware Dependent Modules</strong> Some modules are designed to operate with
    -                    external hardware, typically connected to the Pi's GPIO pins. Before attempting to use any
    -                    of these modules you should ensure you have an understanding of interfacing hardware to the
    -                    PI's GPIO
    -                </blockquote>
    -
    -                <p>Please don't be discouraged by the above warnings. The module system provides a very flexible
    -                    system allowing you to customise the AllSky software to your requirements without having to
    -                    change any code</p>
    -            </div>
    -
    -            <h2>Module Flows</h2>
    -            <div>
    -                <p>To enabled modules to make changes they are run within 'Flows'. For each of the Flows you can
    -                    define which modules run and the order in which they are run.</p>
    -                <details>
    -                    <summary></summary>
    -                    <p>The two areas the modules run in are;</p>
    -                    <ul>
    -                        <li><strong>Via the capture process</strong>
    -                            <ul>
    -                                <li><strong>Day Flow</strong> - These are run after every day time image is
    -                                    captured
    -                                </li>
    -                                <li><strong>Night to Day Transition Flow</strong> - These are run when Allsky
    -                                    switches from Night to Daytime capturing
    -                                </li>
    -                                <li><strong>Night Flow</strong> - These are run after every night time image is
    -                                    captured
    -                                </li>
    -                                <li><strong>Day to Night Transition Flow</strong> - These are run when Allsky
    -                                    switches from Daytime to Nighttime capturing
    -                                </li>
    -                            </ul>
    -                        </li>
    -                        <li><strong>Via a CRON system</strong> - These are run periodically, each modules
    -                            defines
    -                            the frequency with which it runs</li>
    -                    </ul>
    -                    <p>The diagram below shows each stage of the capture process and how modules fit into it.
    -                    </p>
    -
    -                    <img allsky="true" loading="lazy" src="Flows.png" alt="Flows" class="center-image" />
    -                </details>
    -            </div>
    -
    -
    -            <h2>The 'Module Manager'</h2>
    -            <div>
    -                <p>The <span class="managerName">Module Manager</span> is the main interface for managing all of the available modules and in
    -                    which flows they are used. </p>
    -                <details>
    -                    <summary></summary>
    -                    <img allsky="true" loading="lazy" src="Module-Manager.png" alt="Module Manager" />
    -
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Annotation</th>
    -                                <th>Icon</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td class="WebUISetting">Save</td>
    -                                <td><i class="fa fa-save fa-lg"></i></td>
    -                                <td>This will be enabled when any changes have been made</td>
    -                            </tr>
    -                            <tr>
    -                                <td class="WebUISetting">Upload</td>
    -                                <td><i class="fa-solid fa-upload"></i></td>
    -                                <td>This will allow you to upload a zip file containing a new or updated module.
    -                                    Please see the section on uploading a modules for more details on
    -                                    how to use this feature </td>
    -                            </tr>
    -                            <tr>
    -                                <td class="WebUISetting">Flow</td>
    -                                <td></td>
    -                                <td>The flow you wish to manage. If you have un saved changes from the current
    -                                    flow you will be prompted to save them before switching</td>
    -                            </tr>
    -                            <tr>
    -                                <td class="WebUISetting">Settings</td>
    -                                <td><i class="fa-solid fa-gear"></i></td>
    -                                <td>Displays the settings dialog for the <span class="managerName">Module Manager</span></td>
    -                            </tr>
    -                            <tr>
    -                                <td class="WebUISetting">Debug</td>
    -                                <td><i class="fa-solid fa-bug"></i></td>
    -                                <td>Displays the debug data for modules. This icon is only visible if its been
    -                                    enabled in the settings</td>
    -                            </tr>
    -                            <tr>
    -                                <td class="WebUISetting">Reset</td>
    -                                <td><i class="fa-solid fa-rotate-right"></i></td>
    -                                <td>Resets the selected flow to the installation default</td>
    -                            </tr>
    -                            <tr>
    -                                <td class="WebUISetting">Restore</td>
    -                                <td><i class="fa-solid fa-upload"></i></td>
    -                                <td>Restores the flow to the last backup. This option will only be visible if there is a
    -                                    restore poitn available</td>
    -                            </tr>
    -
    -
    -                        </tbody>
    -                    </table>
    -
    -                    <h4>Selecting a flow</h4>
    -                    <div>
    -                        <p>From the drop down list select the flow that you wish to amend. If there are unsaved
    -                            changes for the current flow then you will be prompted to save them before switching
    -                            to a new flow. The current day/night flow will automatically be selected depending
    -                            upon the current time of day
    -                        </p>
    -                    </div>
    -
    -                    <h4>Uploading a module</h4>
    -                    <blockquote>WARNING: Please only upload modules from trusted sources. All modules uploaded are done
    -                        so at your own risk.</blockquote>
    -                    <p>To upload a module it must be contained within a zip file with the same name as the module. So
    -                        for example if I have a module called allsk_test.py the zip file must be called allsky_test.zip
    -                    </p>
    -
    -                    <h4>Enabling a module</h4>
    -                    <div>
    -                        <p>To enable a module drag it from the Available column to the Enabled column. The
    -                            module will become active after the flow has been saved.</p>
    -                        <p>Most modules will require some configuration. Modules can only be configured after
    -                            they have been moved to the Enabled column, clicking the settings button will
    -                            display any configuration options for the module</p>
    -                        <p><strong>Don't</strong> forget to ensure the enabled checkbox is selected otherwise
    -                            the module will not run. You can set modules to be automatically enabled when
    -                            dragging them to the enabled column by setting the appropriate options in the module
    -                            editor settings</p>
    -                    </div>
    -
    -                    <h4>Disabling a Module</h4>
    -                    <div>
    -                        <p>There are two ways to disable a module</p>
    -                        <ul>
    -                            <li>Drag the module to the Available column - This will disable the module and lose
    -                                any settings for it</li>
    -                            <li>Uncheck the enable checkbox - This will disable the module but retain any
    -                                settings. This is the preferred method if you just wish to temporarily disable a
    -                                module</li>
    -                        </ul>
    -
    -                        <h4>Setting the Module execution order</h4>
    -                        <p>The modules will be run in the order they appear in the Enabled column. To change the
    -                            order simply drag the modules up or down the list.</p>
    -                        <p>It is <strong>not</strong> possible to move any modules displayed in red</p>
    -                    </div>
    -
    -                    <h3>Module Manager Settings</h3>
    -                    <div>
    -                        <table class="module-settings vtop">
    -                            <tr>
    -                                <td><img allsky="true" loading="lazy" src="module-manager-settings.png"
    -                                        alt="Module manager Settings" /></td>
    -                                <td>
    -                                    <ul>
    -                                        <li><strong>Enable Watchdog</strong> - When enabled any modules that
    -                                            take longer than the max timer will automatically be disabled</li>
    -                                        <li><strong>Module Max Time</strong> - The maximum time a module can run
    -                                            for before its automatically disabled</li>
    -                                        <li><strong>Auto Enable</strong> - If enabled then when a module is
    -                                            added to the 'Selected' column it is enabled</li>
    -                                        <li><strong>Show Experimental</strong> - When enabled any experimental
    -                                            modules will be available</li>
    -                                        <li><strong>Debug Mode</strong> - When enabled a debug option is
    -                                            available in the toolbar which will show the results and execution
    -                                            time for each module</li>
    -                                        <li><strong>Periodic Timer</strong> - How frequently, in seconds, the periodic
    -                                            flow is run</li>
    -                                    </ul>
    -                                    <blockquote>On occasion you may find that a module has been deactivated. THis can be
    -                                        caused by the module taking longer than the 'Module Max Time' if the watchdog is
    -                                        enabled. To prevent this happening you can either up the time value or disable
    -                                        the watchdog all together</blockquote>
    -                                </td>
    -                            </tr>
    -                        </table>
    -                    </div>
    -                </details>
    -            </div>
    -
    -
    -            <h2 id="Modules">Modules</h2>
    -            <div>
    -                <p>Modules form the heart of the module manager and there is a wide variety available. In this
    -                    section each module is described in details along with all of the available settings.</p>
    -                <h3>Core Modules</h3>
    -                <details>
    -                    <summary></summary>
    -                    <p>The Core Modules are installed along with the main AllSky installer. The available
    -                        modules are</p>
    -
    -                    <h4>Load Image</h4>
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Flows Available In</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td>
    -                                    <ul>
    -                                        <li>Daytime Capture</li>
    -                                        <li>Nighttime Capture</li>
    -                                    </ul>
    -                                </td>
    -                                <td>
    -                                    <p>Loads the last image taken by AllSky. This is always the first module
    -                                        that is run during the daytime and nighttime capture to ensure that
    -                                        the image is available to all subsequent modules</p>
    -                                    <p>This module is enabled in the day and nighttime flows by default so
    -                                        there is no need to install it</p>
    -                                    <blockquote>Since this module must always run first it is not possible
    -                                        to move or remove this module from the day and nighttime capture
    -                                        flows</blockquote>
    -                                </td>
    -                            </tr>
    -                        </tbody>
    -                    </table>
    -
    -                    <h4>Save Image</h4>
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Flows Available In</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td>
    -                                    <ul>
    -                                        <li>Daytime Capture</li>
    -                                        <li>Nighttime Capture</li>
    -                                    </ul>
    -                                </td>
    -                                <td>
    -                                    <p>Saves the final image after all of the modules have run. This is
    -                                        always the last module that is run during the daytime and nighttime
    -                                        capture</p>
    -                                    <p>This module is enabled in the day and nighttime flows by default so
    -                                        there is no need to install it</p>
    -                                    <blockquote>Since this module must always run last it is not possible to
    -                                        move or remove this module from the day and nighttime capture flows
    -                                    </blockquote>
    -                                </td>
    -                            </tr>
    -                        </tbody>
    -                    </table>
    -
    -                    <h4>Mask Image</h4>
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Flows Available In</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td>
    -                                    <ul>
    -                                        <li>Daytime Capture</li>
    -                                        <li>Nighttime Capture</li>
    -                                    </ul>
    -                                </td>
    -                                <td>
    -                                    <p>This applies a mask to the image. This can be useful if there are
    -                                        artefacts outside of the image circle that you wish to remove.
    -                                        Create a mask with white in the areas you wish to keep and black in
    -                                        the areas you wish to be black, see the section on creating and
    -                                        using masks</p>
    -                                </td>
    -                            </tr>
    -                        </tbody>
    -                    </table>
    -
    -
    -                    <h4>Clear Sky</h4>
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Flows Available In</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td>
    -                                    <ul>
    -                                        <li>Nighttime Capture</li>
    -                                    </ul>
    -                                </td>
    -                                <td>
    -                                    <p>This attempts to work out if the sky is clear. It does this by
    -                                        counting the stars in a Region of Interest (ROI) and if this is
    -                                        above a threshold the sky is assumed to be clear. The modules
    -                                        settings allow you to specify the ROI on the image and the
    -                                        parameters for detecting stars. If required the current calculated
    -                                        sky state can be sent to an MQTT broker. Certain other modules can
    -                                        also use the results of this module. For example the Star Count
    -                                        module can be set to only run if the sky is clear, as determined by
    -                                        this module. It is recommended to run this module early in the flow,
    -                                        generally after the mask module</p>
    -                                </td>
    -                            </tr>
    -                        </tbody>
    -                    </table>
    -                    <table class="module-settings vtop">
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-clearksy-settings-home.png"
    -                                    alt="Clearksy Settings" /></td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>Region Of Interest</strong> - The region (ROI) of the image
    -                                        to use for clear sky detection</li>
    -                                    <li><strong>Fallback %</strong> - If no ROI is specified then this %
    -                                        around the center of the image will be used</li>
    -                                    <li><strong>Clear Sky</strong> - The sky will be assumed clear if more
    -                                        than this number of stars are found within the ROI</li>
    -                                    <li><strong>Detection Threshold</strong> - Lowering this value will
    -                                        detect more stars, possibly false positives. Increasing it will
    -                                        detect less stars</li>
    -                                    <li><strong>Distance Threshold</strong> - Any stars fond within this
    -                                        number of pixels of another star will only count as a single star.
    -                                        Reducing this value will increase the number of detected stars but
    -                                        also increase the number of false positives found</li>
    -                                    <li><strong>Star Template Size</strong> - The size of a 'standard' star.
    -                                        reducing this value will detect more stars and false positives</li>
    -                                    <li><strong>Mask Path</strong> - The mask that is applied to the image
    -                                        before any star detection. This is useful to exclude areas of the
    -                                        image that you do not want examined for stars.</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-clearksy-settings-debug.png"
    -                                    alt="Clearksy Settings" />
    -                            </td>
    -                            <td>
    -                                <p>These options are for debugging and should not be used for normal
    -                                    operation</p>
    -                                <ul>
    -                                    <li><strong>Annotate Stars</strong> - Draws a circle around each
    -                                        detected star.
    -                                        This is useful when tuning the detection values</li>
    -                                    <li><strong>Enable Debug Mode</strong> - When selected an image is saved
    -                                        into
    -                                        the allsky/tmp/debug folder after each stage of the detection
    -                                        process. </li>
    -                                    <li><strong>Debug Image</strong> - If an image is specified then this is
    -                                        used
    -                                        rather than the last image taken by AllSky. This is useful for
    -                                        configuring
    -                                        the star detection during the day by using a previously captured
    -                                        image</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-clearksy-settings-mqtt.png"
    -                                    alt="Clearksy Settings" /></td>
    -                            <td>
    -                                <blockquote>This is an advanced option and requires a MQTT broker. The setup
    -                                    and operation of
    -                                    a broker is beyond the scope of this document</blockquote>
    -                                <ul>
    -                                    <li><strong>Enable MQTT</strong> - Select this option to enable
    -                                        publishing to
    -                                        the MQTT Broker</li>
    -                                    <li><strong>MQTT Broker Address</strong> - The FQDN or IP address of the
    -                                        broker
    -                                    </li>
    -                                    <li><strong>MQTT Broker Address</strong> - The port number of the Broker
    -                                    </li>
    -                                    <li><strong>MQTT Username</strong> - The username to login to the Broker
    -                                    </li>
    -                                    <li><strong>MQTT Password</strong> - The password to login to the Broker
    -                                    </li>
    -                                    <li><strong>MQTT Topic</strong> - The topic to post the Sky State to
    -                                    </li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                    </table>
    -
    -
    -                    <h4>Star Count</h4>
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Flows Available In</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td>
    -                                    <ul>
    -                                        <li>Nighttime Capture</li>
    -                                    </ul>
    -                                </td>
    -                                <td>
    -                                    <p>This counts stars in the captures AllSky Image.</p>
    -                                    <p>Stars are counted by using a template that looks like a star and
    -                                        attempts to locate anything in the image that looks like it. This
    -                                        works well when the image is well exposed and masked to remove any
    -                                        areas</p>
    -                                    <p>You will need to experiment with the detection values to get the best
    -                                        results, annotating the stars in the image can be very helpful. The
    -                                        Moon can also cause issues and a future versions of this module
    -                                        will automatically mask the Moon, or any other bright areas and also
    -                                        allow you to disable the module when the Moon meets certain critera
    -                                        such as over a certain % brightness and elevation</p>
    -                                    <blockquote>This module is experimental and as such may produce
    -                                        erroneous results</blockquote>
    -                                </td>
    -                            </tr>
    -                        </tbody>
    -                    </table>
    -                    <table class="module-settings vtop">
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-starcount-settings-home.png"
    -                                    alt="Starcount Settings" />
    -                            </td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>Detection Threshold</strong> - Lowering this value will
    -                                        detect more stars, possibly false positives. Increasing it will
    -                                        detect less stars</li>
    -                                    <li><strong>Distance Threshold</strong> - Any stars fond within this
    -                                        number of pixels of another star will only count as a single star.
    -                                        Reducing this value will increase the number of detcted stars but
    -                                        also increase the number of false positives found</li>
    -                                    <li><strong>Star Template Size</strong> - The size of a 'standard' star.
    -                                        reducing this value will detect more stars and false positives</li>
    -                                    <li><strong>Mask Path</strong> - The mask that is applied to the image
    -                                        before any star detection. This is useful to exclude areas of the
    -                                        image that you do not want examined for stars.</li>
    -                                    <li><strong>Use Clear Sky</strong> - If the 'Clear Sky' module is
    -                                        running then its results will be used to determine if stars should
    -                                        be counted. If the sky is not clear then no attempt will be made to
    -                                        count stars</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-starcount-settings-debug.png"
    -                                    alt="Starcount Settings" />
    -                            </td>
    -                            <td>
    -                                <blockquote>These options are for debugging and should not be used for
    -                                    normal operation</blockquote>
    -                                <ul>
    -                                    <li><strong>Annotate Stars</strong> - Draws a circle around each
    -                                        detected star. This is useful when tuning the detection values</li>
    -                                    <li><strong>Enable Debug Mode</strong> - When selected an image is saved
    -                                        into the allsky/tmp/debug folder after each stage of the detection
    -                                        process. </li>
    -                                    <li><strong>Debug Image</strong> - If an image is specified then this is
    -                                        used rather than the last image taken by AllSky. This is useful for
    -                                        configuring the star detection during the day by using a previosuly
    -                                        captured image</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                    </table>
    -
    -                    <h4>Meteor Detection</h4>
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Flows Available In</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td>
    -                                    <ul>
    -                                        <li>Nighttime Capture</li>
    -                                    </ul>
    -                                </td>
    -                                <td>
    -                                    <p>This attempts to detect any meteors in the image. The detection
    -                                        method looks for hard edged in the images and thus is susceptible to
    -                                        false positives if the image is not masked.</p>
    -                                    <p>You will need to experiment with the detection values to get the best
    -                                        results, annotating the meteors in the image can be very helpful.
    -                                        The Moon can also cause issues and a future versions of this module
    -                                        will automatically mask the Moon, or any other bright areas and also
    -                                        allow you to disable the module when the Moon meets certain criteria
    -                                        such as over a certain % brightness and elevation</p>
    -                                    <blockquote>This module is experimental and as such may produce
    -                                        erroneous results</blockquote>
    -                                </td>
    -                            </tr>
    -                        </tbody>
    -                    </table>
    -                    <table class="module-settings vtop">
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-meteor-settings-home.png"
    -                                    alt="Meteorcount Settings" />
    -                            </td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>Mask Path</strong> - The mask that is applied to the image
    -                                        before any meteor detection. This is useful to exclude areas of the
    -                                        image that you do not want examined for stars.</li>
    -                                    <li><strong>Minimum</strong> - Any streaks longer than this number of
    -                                        pixels will be considered a meteor</li>
    -                                    <li><strong>Use Clear Sky</strong> - If the 'Clear Sky' module is
    -                                        running then its results will be used to determine if meteors should
    -                                        be detected. If the sky is not clear then no meteor detection will
    -                                        be attempted</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-meteor-settings-debug.png"
    -                                    alt="Meteorcount Settings" />
    -                            </td>
    -                            <td>
    -                                <blockquote>These options are for debugging and should not be used for
    -                                    normal operation</blockquote>
    -                                <ul>
    -                                    <li><strong>Annotate Meteors</strong> - Any detected meteors will be
    -                                        highlighted in the image</li>
    -                                    <li><strong>Enable Debug Mode</strong> - When selected an image is saved
    -                                        into the allsky/tmp/debug folder after each stage of the detection
    -                                        process. </li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                    </table>
    -
    -                    <h4>Export</h4>
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Flows Available In</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td>
    -                                    <ul>
    -                                        <li>Daytime Capture</li>
    -                                        <li>Nighttime Capture</li>
    -                                    </ul>
    -                                </td>
    -                                <td>
    -                                    <p>Exports all internal AllSky variables to a json file. By default all
    -                                        environment variables prefixed with AS_ are exported but via the
    -                                        module options other AllSky variables can also be made available.
    -                                        This can be used by external programs</p>
    -                                </td>
    -                            </tr>
    -                        </tbody>
    -                    </table>
    -                    <table class="module-settings vtop">
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-export-settings-home.png"
    -                                    alt="Export Settings" /></td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>File Location</strong> - The location to save the file.
    -                                        Certain AllSky variables can be used to construct the path. See the
    -                                        variables section for more details</li>
    -                                    <li><strong>Extra Data To Export</strong> - A comma separated list of
    -                                        additional variables to save</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                    </table>
    -
    -                    <h4>Overlay</h4>
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Flows Available In</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td>
    -                                    <ul>
    -                                        <li>Daytime Capture</li>
    -                                        <li>Nighttime Capture</li>
    -                                    </ul>
    -                                </td>
    -                                <td>
    -                                    <p>Overlays data on the captured image. This module applies the fields
    -                                        defined in the 'Overlay Editor'. A full description of the editor is
    -                                        available in this documentation so is not covered here.</p>
    -                                    <blockquote>Typically the overlay module will run towardsm if not at the
    -                                        end of the flow. This will allow it access to other variables
    -                                        created by modules</blockquote>
    -                                </td>
    -                            </tr>
    -                        </tbody>
    -                    </table>
    -
    -                    <h4>Script</h4>
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Flows Available In</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td>
    -                                    <ul>
    -                                        <li>Daytime Capture</li>
    -                                        <li>Nighttime Capture</li>
    -                                    </ul>
    -                                </td>
    -                                <td>
    -                                    <p>This allows a script to be run. This should only be used by users
    -                                        that understand how script are developer/run on Linux.
    -                                        <strong>Extreme</strong> care must be taken when using this module
    -                                        is it could cause the main AllSky software to stop operating
    -                                    </p>
    -                                </td>
    -                            </tr>
    -                        </tbody>
    -                    </table>
    -                    <table class="module-settings vtop">
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-script-settings-home.png"
    -                                    alt="Script Settings" /></td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>File Location</strong> - The script to execute. The module
    -                                        will check to ensure it exists and is executable before any attempt
    -                                        is made to run it</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                    </table>
    -
    -                    <h4>Save Image Data</h4>
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Flows Available In</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td>
    -                                    <ul>
    -                                        <li>Daytime Capture</li>
    -                                        <li>Nighttime Capture</li>
    -                                    </ul>
    -                                </td>
    -                                <td>
    -                                    <p>This module writes image related data to a database allowing it to be
    -                                        graphed in the Web UI</p>
    -                                </td>
    -                            </tr>
    -                        </tbody>
    -                    </table>
    -
    -                </details>
    -
    -                <h3>User Modules</h3>
    -                <details>
    -                    <summary></summary>
    -                    <p>The User Modules are installed from a separate GitHub repository and not included with
    -                        the mainAllSky installation</p>
    -
    -                    <h4>Cloud Cover</h4>
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Flows Available In</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td>
    -                                    <ul>
    -                                        <li>Nighttime Capture</li>
    -                                    </ul>
    -                                </td>
    -                                <td>
    -                                    <blockquote><strong>This is an advanced module and requires external
    -                                            hardware for its operation</strong></blockquote>
    -                                    <p>This module use an <a
    -                                            href="https://www.sparkfun.com/datasheets/Sensors/Temperature/MLX90614_rev001.pdf"
    -                                            target="_blank">MLX90614</a> to determine the cloud cover. The
    -                                        MLX90614 is a non contact Infra Red thermometer. It is used to
    -                                        measure the temperature of the sky and compare it to the ambient
    -                                        temperature. Generally the difference between the Sky temperature
    -                                        and
    -                                        ambient can be used to determine the amount of cloud. The exact
    -                                        theory for the calculations is beyond the scope of this
    -                                        documentation
    -                                    </p>
    -                                    <p>One of the big issues with using any for of sky temperature
    -                                        measurement is the sensor and water/snow ! If the sensor gets
    -                                        wet/covered then the readings will be useless. The best way to
    -                                        mounts the sensor is in a protective enclosure with the sensor
    -                                        mounted at 90 degrees to the sky and some form of reflective device
    -                                        used to reflect the sky at the sensor</p>
    -                                    <p>Two different methods are available for determining the cloud cover
    -                                    </p>
    -                                    <ul>
    -                                        <li><strong>Simple</strong> - The difference between Sky and ambient
    -                                            temperature is used to determine the cloud cover.</li>
    -                                        <li><strong>Advanced</strong> - Uses a polynomial model to correct
    -                                            the sky temperature reading. The forumale used in thei method have
    -                                            been derived from <a
    -                                                href="https://indiduino.wordpress.com/2013/02/02/meteostation/"
    -                                                target="_blank">https://indiduino.wordpress.com/2013/02/02/meteostation/</a>
    -                                            and <a
    -                                                href="https://lunaticoastro.com/aagcw/TechInfo/SkyTemperatureModel.pdf"
    -                                                target="_blank">https://lunaticoastro.com/aagcw/TechInfo/SkyTemperatureModel.pdf</a>
    -                                        </li>
    -                                    </ul>
    -                                </td>
    -                            </tr>
    -                        </tbody>
    -                    </table>
    -                    <table class="module-settings vtop">
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-cloudcover-settings-sensor.png"
    -                                    alt="Cloud Cover Settings" />
    -                            </td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>i2C Address</strong> - The default i2C address for the
    -                                        MLX90614 is 0x5A. If you need a different address specify it here in
    -                                        Hex</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-cloudcover-settings-settings.png"
    -                                    alt="Cloud Cover Settings" />
    -                            </td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>Clear Below</strong> - Below this Sky temperature it is
    -                                        assumed to be clear</li>
    -                                    <li><strong>Cloudy Above</strong> - Above this Sky temperature it is
    -                                        assumed to be cloudy</li>
    -                                </ul>
    -                                <p>Between the two above temperatures the sky is assumed to be partially
    -                                    cloudy</p>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-cloudcover-settings-advanced.png"
    -                                    alt="Cloud Cover Settings" />
    -                            </td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>Use Advanced Mode</strong> - If enabled the polynomial model
    -                                        will be used</li>
    -                                    <li><strong>k1</strong> - TBC</li>
    -                                    <li><strong>k2</strong> - TBC</li>
    -                                    <li><strong>k3</strong> - TBC</li>
    -                                    <li><strong>k4</strong> - TBC</li>
    -                                    <li><strong>k5</strong> - TBC</li>
    -                                    <li><strong>k6</strong> - TBC</li>
    -                                    <li><strong>k7</strong> - TBC</li>
    -                                </ul>
    -                                <p>Details of the formulae used cen be found on the lunaticoastro website <a
    -                                        href="https://lunaticoastro.com/aagcw/TechInfo/SkyTemperatureModel.pdf"
    -                                        target="_blank">here</a></p>
    -                                <blockquote>Setting these values requires a lot of experimentation</blockquote>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td>
    -                                <img allsky="true" loading="lazy" src="cloud-cover.png" alt="Cloud Cover Circuit"
    -                                    width="500px" />
    -                            </td>
    -                            <td>
    -                                <p>A typical connection diagram for a Cloud Cover sensor. As mentioned above the
    -                                    process of detecting clouds is not a simple one. The basic principle is to
    -                                    measure the difference between the ambient temperature and the temperature
    -                                    of the sky. The ambient is fairly easy to measure but the sky temperature
    -                                    presenets a few problems</p>
    -                                <ul>
    -                                    <li>Field of View (FOV) - A narrow FOV will lead to results just above the
    -                                        sensor whereas a wide FOV will have less sensitivity. Testing has found
    -                                        that the narrow FOV versions provide a more accurate reading albeit with
    -                                        the much narrower FOV</li>
    -                                    <li>Moisture - Since the sensor needs to be pointed vertically if any
    -                                        moisture from rain/snow sits on it then the reading will be inaccurate
    -                                    </li>
    -                                </ul>
    -                                <p>The moisture problem is a little more difficult to solve and the best
    -                                    solution found is to mount the sensor horizontally and use a reflective
    -                                    surface to reflect the sky onto the sensor. This will allos the sensor to
    -                                    stay dry.</p>
    -
    -                                <p>Several environment variables are created that can be used in the overlay
    -                                    manager</p>
    -                                <ul>
    -                                    <li><strong>CLOUDAMBIENT</strong> - The ambient temperature</li>
    -                                    <li><strong>CLOUDSKY</strong> - The sky temperature</li>
    -                                    <li><strong>CLOUDCOVER</strong> - A string, either 'Clear', 'Partial' or
    -                                        'Coudy'</li>
    -                                    <li><strong>CLOUDCOVERPERCENT</strong> - The percentage of cloud cover, only
    -                                        available when using the Advanced method</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                    </table>
    -
    -                    <h4>Dew Heater</h4>
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Flows Available In</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td>
    -                                    <ul>
    -                                        <li>Daytime Capture</li>
    -                                        <li>Nighttime Capture</li>
    -                                        <li>Periodic</li>
    -                                    </ul>
    -                                </td>
    -                                <td>
    -                                    <blockquote><strong>This is an advanced module and requires external
    -                                            hardware for its operation</strong></blockquote>
    -                                    <p>This module allows you to control a digital GPIO pin that can in turn
    -                                        be used to drive a dew heater. The GPIO pin cannot directly drive
    -                                        the heater instead it must be used to drive some form of switch,
    -                                        relay / transistor etc to control the heater. The electronics to do
    -                                        this are beyond the scope of this documentation
    -                                    </p>
    -                                    <p>Some form of sensor is required to obtain the current temperature and
    -                                        humidity. The module supports the most common form of sensors
    -                                        available on the market today</p>
    -                                </td>
    -                            </tr>
    -                        </tbody>
    -                    </table>
    -                    <table class="module-settings vtop">
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-dew-settings-sensor.png"
    -                                    alt="Dew Heater Settings" /></td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>Sensor Type</strong> - The type of sensor to use
    -                                        <ul>
    -                                            <li><strong>SHT31</strong> - Uses an SHT31 on i2c address 0x44.
    -                                                The alert mode on the SHT31 is not supported</li>
    -                                            <li><strong>DHT22</strong> - Uses a DHT22 sensor connected to a
    -                                                GPIO pin, the pin is selected in the 'Input Pin' option</li>
    -                                            <li><strong>DHT11</strong> - Uses a DHT11 sensor connected to a
    -                                                GPIO pin, the pin is selected in the 'Input Pin' option</li>
    -                                            <li><strong>BME280-I2C</strong> - Uses a BME280 in i2c mode on
    -                                                address 0x77</li>
    -                                        </ul>
    -
    -                                    </li>
    -                                    <li><strong>Input Pin</strong> - The GPIO pin for the DHT11/22 sensors
    -                                    </li>
    -                                    <li><strong>i2C Address</strong> - If your sensor uses i2c and has a
    -                                        different address to the standard one for the sensor enter the HEX
    -                                        address here</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-dew-settings-heater.png"
    -                                    alt="Dew Heater Settings" /></td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>Heater Pin</strong> - The GPIO pin for the heater control
    -                                    </li>
    -                                    <li><strong>Startup Mode</strong> - Determines if the heater should be
    -                                        on when allsky starts or off</li>
    -                                    <li><strong>Invert Relay</strong> - Normally the GPIO pin will go high
    -                                        to enable a relay. Selecting this option if the relay is wired to
    -                                        activate on the GPIO pin going Low</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-dew-settings-control.png"
    -                                    alt="Dew Heater Settings" /></td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>Delay</strong> - The delay in seconds between sensor
    -                                        readings. If running in day or night flows then the frame exposure
    -                                        time needs to be taken into account.</li>
    -                                    <li><strong>Limit</strong> - If the temperature is within this many
    -                                        degrees of the dew point the heater will be enabled or disabled</li>
    -                                    <li><strong>Forced Temperature</strong> - If the temperature is below
    -                                        this value the heater will always be enabled</li>
    -                                    <li><strong>Max Heater Time</strong> - Not yet implemented</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td>
    -                                <img allsky="true" loading="lazy" src="dew-heater.png" alt="Dew Heater Circuit"
    -                                    width="800px" />
    -                            </td>
    -                            <td>
    -                                <p>A typical connection diagram for a dew heater. In this example a SHT31 is
    -                                    being used to provide the temperature and humidity data. The module will
    -                                    then determine if the heater is required and by enabling the relevant GPIO
    -                                    pin, 20 in this case</p>
    -                                <blockquote><strong>WARNING</strong> driving relays directly from the pi's GPIO
    -                                    pins can be problematic and cause damage to your Pi. If you are using a
    -                                    module then please ensure its has a <a href="https://en.wikipedia.org/wiki/Snubber"
    -                                        target="_blank">snubber</a>
    -                                    and is optically isolated. Please check this <a
    -                                        href="https://forums.raspberrypi.com/viewtopic.php?f=91&t=83372&p=1225448#p1225448"
    -                                        target="_blank">FAQ</a> for more details.</blockquote>
    -
    -                                <p>Several environment variables are created that can be used in the overlay
    -                                    manager</p>
    -                                <ul>
    -                                    <li><strong>DEWCONTROLAMBIENT</strong> - The ambient temperature</li>
    -                                    <li><strong>DEWCONTROLDEW</strong> - The calculated Dew point</li>
    -                                    <li><strong>DEWCONTROLHUMIDITY</strong> - The Humidity</li>
    -                                    <li><strong>DEWCONTROLHEATER</strong> - Either 'on' or 'off' indicating the
    -                                        status of the dew heater</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                    </table>
    -
    -
    -                    <h4>GPIO</h4>
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Flows Available In</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td>
    -                                    <ul>
    -                                        <li>Night/Day Transition</li>
    -                                        <li>Day/Night Transition</li>
    -                                    </ul>
    -                                </td>
    -                                <td>
    -                                    <blockquote><strong>This is an advanced module and requires external
    -                                            hardware for its operation</strong></blockquote>
    -                                    <p>This module allows a GPIO pin's state to be set on the transitions
    -                                        between day/night and night/day. This could for example be used to
    -                                        trigger some external electronics to cover and uncover the camera to
    -                                        protect it from direct sunlight in very warm climates</p>
    -                                </td>
    -                            </tr>
    -                        </tbody>
    -                    </table>
    -                    <table class="module-settings vtop">
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-gpio-settings-home.png"
    -                                    alt="GPIO Settings" /></td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>GPIO Pin</strong> - The GPIO pin to set</li>
    -                                    <li><strong>Pin State</strong> - The state to set the pin to</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                    </table>
    -
    -                    <h4>Discord</h4>
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Flows Available In</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td>
    -                                    <ul>
    -                                        <li>Nighttime Capture</li>
    -                                        <li>Daytime Capture</li>
    -                                        <li>End of Night</li>
    -                                    </ul>
    -                                </td>
    -                                <td>
    -                                    <blockquote><strong>This is an advanced module and requires
    -                                            configuration in Discord</strong></blockquote>
    -                                    <blockquote>
    -                                        <strong>Python Version</strong> This module requires python 3.9.0 or
    -                                        greater. The module installer will not allow the module to be
    -                                        installed if you do not have the correct version of python installed
    -                                    </blockquote>
    -                                    <p>This module allows images to be sent to Discord channels. Currently
    -                                        the following images can be sent to Discord</p>
    -                                    <ul>
    -                                        <li>Daytime Images - To post these include the module in the daytime
    -                                            flow</li>
    -                                        <li>Nightime Images - To post these include the module in the
    -                                            nighttime flow</li>
    -                                        <li>Startrails - To post these include the module in the endofnight
    -                                            flow</li>
    -                                        <li>Keograms - To post these include the module in the endofnight
    -                                            flow</li>
    -                                        <li>Timelapse videos - To post these include the module in the
    -                                            endofnight flow</li>
    -                                    </ul>
    -
    -                                    <blockquote>
    -                                        <strong>Rate Limits</strong> Discord implement a <a
    -                                            href="https://discord.com/developers/docs/topics/rate-limits"
    -                                            target="_blank">rate limit</a> on the API. It is very unlikely
    -                                        that by using this module you will hit any of the limits
    -                                    </blockquote>
    -                                    <blockquote>
    -                                        <strong>Post Sizes</strong> Discord implements a limit of 8Mb for
    -                                        any
    -                                        posted item. Its possible that the timelapse videos may exceed this
    -                                        rate so if you wish to send them to Discord you will have to
    -                                        configure AllskY to ensure that the video is less than 8Mb. The
    -                                        module will check before sending the file and if it exceeds the
    -                                        Discord limit it will not be sent and an error logged in the allsky
    -                                        log file
    -                                    </blockquote>
    -                                    <p>You will need to create Webhooks in your Discord server. These can be
    -                                        created from the server settings.</p>
    -                                    <img allsky="true" loading="lazy" src="discord-webhooks.png"
    -                                        alt="Discord Webhook" />
    -                                </td>
    -                            </tr>
    -                        </tbody>
    -                    </table>
    -                    <table class="module-settings vtop">
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-discord-settings-day.png"
    -                                    alt="Discord Settings" /></td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>Post Daytime Images</strong> - Select this option to post
    -                                        daytime images to the Discord Server</li>
    -                                    <li><strong>Daytime Count</strong> - Every x images send the current
    -                                        image to the Discord server</li>
    -                                    <li><strong>Daytime Webhook</strong> - The webhook for the daytime
    -                                        images. The value for this field is created in the Discord servers
    -                                        settings under integration</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-discord-settings-night.png"
    -                                    alt="Discord Settings" /></td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>Post Nighttime Images</strong> - Select this option to post
    -                                        nighttime images to the Discord Server</li>
    -                                    <li><strong>Nighttime Count</strong> - Every x images send the current
    -                                        image to the Discord server</li>
    -                                    <li><strong>Nighttime Webhook</strong> - The webhook for the nighttime
    -                                        images. The value for this field is created in the Discord servers
    -                                        settings under integration</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-discord-settings-startrails.png"
    -                                    alt="Discord Settings" />
    -                            </td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>Post Startrail Images</strong> - Select this option to post
    -                                        startrail images to the Discord Server</li>
    -                                    <li><strong>Startrail Webhook</strong> - The webhook for the startrail
    -                                        images. The value for this field is created in the Discord servers
    -                                        settings under integration</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-discord-settings-keograms.png"
    -                                    alt="Discord Settings" />
    -                            </td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>Post Keograms Images</strong> - Select this option to post
    -                                        keogram images to the Discord Server</li>
    -                                    <li><strong>Keograms Webhook</strong> - The webhook for the keogram
    -                                        images. The value for this field is created in the Discord servers
    -                                        settings under integration</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-discord-settings-timelapse.png"
    -                                    alt="Discord Settings" />
    -                            </td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>Post Timelapse Videos</strong> - Select this option to post
    -                                        timelapse videos to the Discord Server</li>
    -                                    <li><strong>Timelapse Webhook</strong> - The webhook for the timelapse
    -                                        videos. The value for this field is created in the Discord servers
    -                                        settings under integration</li>
    -                                </ul>
    -                                <blockquote>
    -                                    <strong>Post Sizes</strong> Discord implements a limit of 8Mb for any
    -                                    posted item. Its possible that the timelapse videos may exceed this rate
    -                                    so if you wish to send them to Discord you will have to configure AllskY
    -                                    to ensure that the video is less than 8Mb. The module will check before
    -                                    sending the file and if it exceeds the Discord limit it will not be sent
    -                                    and an error logged in the allsky log file
    -                                </blockquote>
    -                            </td>
    -                        </tr>
    -                    </table>
    -
    -                    <h4>Rain</h4>
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Flows Available In</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td>
    -                                    <ul>
    -                                        <li>Nighttime Capture</li>
    -                                        <li>Daytime Capture</li>
    -                                    </ul>
    -                                </td>
    -                                <td>
    -                                    <blockquote>This is an advanced module and requires external hardware
    -                                    </blockquote>
    -                                    <p>This module uses an external sensor to detect rain. There are various
    -                                        cheap sensors available to detect rain that either provide an analog
    -                                        or digital output. This module only supports sensors with a digital
    -                                        output</p>
    -                                </td>
    -                            </tr>
    -                        </tbody>
    -                    </table>
    -                    <table class="module-settings vtop">
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-rain-settings-home.png"
    -                                    alt="Rain Settings" /></td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>GPIO Pin</strong> - The GPIO pin the rain sensor is
    -                                        connected to</li>
    -                                    <li><strong>Invert Sensor</strong> - Normally the sensor will be high
    -                                        for clear and low for rain. This setting will reverse this</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td>
    -                                <img allsky="true" loading="lazy" src="rain-detection.png" alt="Cloud Cover Circuit"
    -                                    width="500px" />
    -                            </td>
    -                            <td>
    -                                <p>A typical connection diagram for rain detection using a cheap rain detection
    -                                    module. These modules provide both an analog and digital output but this
    -                                    module only works with the digital output.</p>
    -                                <p>From testing these cheap sensors will work but the contact boards suffer
    -                                    badly from corrosion. A much better module is described <a
    -                                        href="https://www.kemo-electronic.de/en/House/Garden/M152-Rain-Sensor-12-V-DC.php"
    -                                        taregt="_blank">here</a>. Whilst more expensive and requiring a 12 volt
    -                                    supply this is a far superior rain/snow detector. </p>
    -                                <p>Two environment variables are created that can be used in other
    -                                    modules or the overlay manager</p>
    -                                <ul>
    -                                    <li><strong>RAINSTATE</strong> - A text string either 'Not Raining'
    -                                        or 'Raining'</li>
    -                                    <li><strong>ALLSKYRAINFLAG</strong> - A text string either 'False'
    -                                        or 'True'</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                    </table>
    -
    -                    <h4>Open Weather Map</h4>
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Flows Available In</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td>
    -                                    <ul>
    -                                        <li>Periodic</li>
    -                                    </ul>
    -                                </td>
    -                                <td>
    -                                    <p>This module reads weather data from the free Open Weather Map API. To
    -                                        use this module you will need to signup for a free API key on the <a
    -                                            href="https://openweathermap.org/" target="_blank">openweathermap</a>
    -                                        website. The location used
    -                                        for the weather is the coordinates you have specified in the main
    -                                        AllSky settings</p>
    -                                    <blockquote>The free tier of the api is limited to 1,000 calls per day
    -                                        so you will need to configure the settings appropriately. Reading
    -                                        the data every 10 minutes is more than frequent enough and will
    -                                        ensure you do not exceed the api limit</blockquote>
    -                                    <p>Several environment variables are created that can be used in the
    -                                        overlay manager</p>
    -                                    <ul>
    -                                        <li><strong>OWWEATHER</strong> - A text string representing the
    -                                            weather</li>
    -                                        <li><strong>OWWEATHERDESCRIPTION</strong> - A text string
    -                                            representing the weather</li>
    -                                        <li><strong>OWTEMP</strong> - The temperature</li>
    -                                        <li><strong>OWTEMPFEELSLIKE</strong> - What the temperature feels
    -                                            like</li>
    -                                        <li><strong>OWTEMPMIN</strong> - The minimum temperature</li>
    -                                        <li><strong>OWTEMPMAX</strong> - The maximum temperature</li>
    -                                        <li><strong>OWPRESSURE</strong> - The pressure</li>
    -                                        <li><strong>OWHUMIDITY</strong> - The humidity</li>
    -                                        <li><strong>OWWINDSPEED</strong> - The wind speed</li>
    -                                        <li><strong>OWWINDDIRECTION</strong> - The wind direction</li>
    -                                        <li><strong>OWWINDGUST</strong> - The wind gust speed</li>
    -                                        <li><strong>OWCLOUDS</strong> - The cloud cover</li>
    -                                        <li><strong>OWRAIN1HR</strong> - Rainfall within the last hour</li>
    -                                        <li><strong>OWRAIN3HR</strong> - Rainfall within the last three
    -                                            hours</li>
    -                                        <li><strong>OWSUNRISE</strong> - Time the Sun rises</li>
    -                                        <li><strong>OWSUNSET</strong> - Time the Sun sets</li>
    -                                    </ul>
    -                                </td>
    -                            </tr>
    -                        </tbody>
    -                    </table>
    -                    <table class="module-settings vtop">
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-openweathermap-settings-home.png"
    -                                    alt="Open Weather Map Settings" /></td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>API Key</strong> - The api key you generated after signing
    -                                        up to Open Weather Map</li>
    -                                    <li><strong>Filename</strong> - The name of the 'extra data' file the
    -                                        Open Weather Map data is saved in. Normally you should not need to
    -                                        change this</li>
    -                                    <li><strong>Read Every</strong> - Call the API every x seconds. Be
    -                                        mindful of the 1,000 API limit per day when setting this value</li>
    -                                    <li><strong>Units</strong> - The units to express the data in, Either
    -                                        'Standard' (SI Units), 'Metric' or 'Imperial'</li>
    -                                    <li><strong>Expiry Time</strong> - The number of seconds the data is
    -                                        valid for, see the overlay manager documentation for details</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                    </table>
    -
    -                    <h4>GPS</h4>
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Flows Available In</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td>
    -                                    <ul>
    -                                        <li>Periodic</li>
    -                                    </ul>
    -                                </td>
    -                                <td>
    -                                    <p>This module was developed for those that use their AllSky cameras in mobile
    -                                        locations. It was developed to allow the Pi's location and clock to be set from
    -                                        the GPS data</p>
    -                                    <p>This module requires a gps connected to the pi that can be managed by gpsd</p>
    -                                    <blockquote><strong>NOTE:</strong> The HDMI and Wifi on the Pi 4 is VERY noise and
    -                                        will interfere with most GPS modules. To get around this problem please ensure
    -                                        that the GPS receiver and antenna is mounted at least three feet away from the
    -                                        Pi.</blockquote>
    -                                    <h4>Time synchronisation</h4>
    -                                    <p>Even if you set the time sync options in the GPS module the time will only be
    -                                        synchronised if the pi is NOT having its time updated from the internet.</p>
    -                                    <p>To test if the time is currently being synchronised fom the internet enter the
    -                                        following command</p>
    -                                    <div><tt>timedatectl status</tt></div>
    -                                    <p>Output similar to the following will be produced</p>
    -
    -                                    <div class="modulecode">
    -                                        <div><tt>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Local
    -                                                time: Fri 2023-02-03 23:18:36 GMT</tt></div>
    -                                        <div><tt>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Universal
    -                                                time: Fri 2023-02-03 23:18:36 UTC</tt></div>
    -                                        <div><tt>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;RTC
    -                                                time: n/a</tt></div>
    -                                        <div><tt>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Time
    -                                                zone: Europe/London (GMT, +0000)</tt></div>
    -                                        <div><tt>System clock synchronized: <span class="red">no</span></tt></div>
    -                                        <div><tt>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;NTP
    -                                                service: <span class="red">inactive</span></tt></div>
    -                                        <div><tt>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;RTC in
    -                                                local TZ: <span class="red">no</span></tt></div>
    -                                    </div>
    -
    -                                    <p>Note that the 'System clock synchronized' value is 'no'. With this value 'no' the
    -                                        GPS module will be allowed to set the time</p>
    -                                    <p>Several environment variables are created that can be used in the overlay manager
    -                                    </p>
    -                                    <ul>
    -                                        <li><strong>PIGPSFIX</strong> - A text string, either 'Yes' or 'No' indicating
    -                                            if the GPS has a fix</li>
    -                                        <li><strong>PIGPSUTC</strong> - The UTC time from the GPS</li>
    -                                        <li><strong>PIGPSLOCAL</strong> - The local time from the GPS</li>
    -                                        <li><strong>PIGPSOFFSET</strong> - The time offset from UTC in hours</li>
    -                                        <li><strong>PIGPSLAT</strong> - The GPS latitude in Degrees, Minutes and Seconds
    -                                        </li>
    -                                        <li><strong>PIGPSLON</strong> - The GPS longitude in Degrees, Minutes and
    -                                            Seconds</li>
    -                                        <li><strong>PIGPSLATDEC</strong> - The GPS latitude in decimal degrees, Minutes
    -                                            and Seconds</li>
    -                                        <li><strong>PIGPSLONDEC</strong> - The GPS longitude in decimal degrees, Minutes
    -                                            and Seconds</li>
    -                                        <li><strong>PIGPSFIXDISC</strong> - The lat/lon discrepancy string, if there is
    -                                            a decrepancy found</li>
    -                                    </ul>
    -                                </td>
    -                            </tr>
    -                        </tbody>
    -                    </table>
    -                    <table class="module-settings vtop">
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-gps-home.png" alt="GPS Settings" /></td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>LAT/LON Warning</strong> - If enabled a warning will be generated, both
    -                                        in the log files and as an allsky variable if the GPS position does not match
    -                                        the All Sky settings. The comparison is done to 2 decimal places to allow for
    -                                        GPS fluctuation</li>
    -                                    <li><strong>Set LAT/LON</strong> - If enabled the lat/lon in the AllSky settings
    -                                        will get set from the GPS</li>
    -                                    <li><strong>Set Time</strong> - If enabled the tim eon the Pi will be set from the
    -                                        GPS</li>
    -                                    <li><strong>Set Every</strong> - If the 'Set Time' option is enabled the time on the
    -                                        Pi will be set every this number of seconds</li>
    -                                    <li><strong>Extra Data Filename</strong> - The name of the file to create the GPS
    -                                        data in for the overlay manager. Normally you will nto need to change this</li>
    -                                    <li><strong>Discrepancy Warning</strong> - if the 'LAt/Lon Warning' is enabled and a
    -                                        discrepancy is found this text will be set in the variable for the overlay
    -                                        manager</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-gps-obfuscate.png"
    -                                    alt="GPS Obfuscation Settings" /></td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>Obfuscate Position</strong> - If enabled the values below will be used
    -                                        to modify the GPS position. This is designed to allow you to display the GPS
    -                                        position on an overlay without giving away your exact position</li>
    -                                    <li><strong>Latitude Metres</strong> - The number of metres to offset the latitude
    -                                        by, can be a psotive or negative number</li>
    -                                    <li><strong>Longitude Metres</strong> - The number of metres to offset the longitude
    -                                        by, can be a psotive or negative number</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="gps.png" alt="GPS connection" /></td>
    -                            <td>
    -                            </td>
    -                        </tr>
    -                    </table>
    -
    -                    <h4>PI Status</h4>
    -                    <table class="module-info vtop">
    -                        <thead>
    -                            <tr>
    -                                <th>Flows Available In</th>
    -                                <th>Description</th>
    -                            </tr>
    -                        </thead>
    -                        <tbody>
    -                            <tr>
    -                                <td>
    -                                    <ul>
    -                                        <li>Periodic</li>
    -                                    </ul>
    -                                </td>
    -                                <td>
    -                                    <p>This module reads information about the Pi and makes it availbale for use in the
    -                                        overlay manager</p>
    -
    -                                    <p>Several environment variables are created that can be used in the overlay manager
    -                                    </p>
    -                                    <ul>
    -                                        <li><strong>DISKSIZE</strong> - The size of the main disk in the Pi</li>
    -                                        <li><strong>DISKUSAGE</strong> - Amount of space used on the disk</li>
    -                                        <li><strong>DISKFREE</strong> - Amount of free space on the disk</li>
    -                                        <li><strong>CPUTEMP</strong> - CPU temp in C/F Only available if the Allsky temp
    -                                            settings is Celcius or Fahrenheit</li>
    -                                        <li><strong>CPUTEMP_C</strong> - CPU temp in C Only available if the Allsky temp
    -                                            settings is set to Celcius</li>
    -                                        <li><strong>CPUTEMP_F</strong> - CPU temp in F Only available if the Allsky temp
    -                                            settings is set to Fahrenheit</li>
    -                                        <li><strong>THROTTLEDBINARY</strong> - Output of vcgencmd get_throttled</li>
    -                                        <li><strong>TSTAT{X}</strong> - Throttled bits, see table below</li>
    -                                        <li><strong>TSTATSUMARYTEXT</strong> - Textual summary of all of the tstats bits
    -                                        </li>
    -                                    </ul>
    -                                    <blockquote>Several other variables are also available, These are mainly related to
    -                                        clock frequencies and voltages</blockquote>
    -                                    <h4>Tstat bits</h4>
    -                                    <p>These values are text and either 'true' or 'false'</p>
    -                                    <table style="width: 50%">
    -                                        <tr>
    -                                            <th>Variable</th>
    -                                            <th>Meaning</th>
    -                                        </tr>
    -                                        <tr>
    -                                            <td>TSTAT0</td>
    -                                            <td>Under-voltage detected</td>
    -                                        </tr>
    -                                        <tr>
    -                                            <td>TSTAT1</td>
    -                                            <td>Arm frequency capped</td>
    -                                        </tr>
    -                                        <tr>
    -                                            <td>TSTAT2</td>
    -                                            <td>Currently throttled</td>
    -                                        </tr>
    -                                        <tr>
    -                                            <td>TSTAT2</td>
    -                                            <td>Currently throttled</td>
    -                                        </tr>
    -                                        <tr>
    -                                            <td>TSTAT3</td>
    -                                            <td>Soft temperature limit active</td>
    -                                        </tr>
    -                                        <tr>
    -                                            <td>TSTAT16</td>
    -                                            <td>Under-voltage has occurred</td>
    -                                        </tr>
    -                                        <tr>
    -                                            <td>TSTAT17</td>
    -                                            <td>Arm frequency capping has occurred</td>
    -                                        </tr>
    -                                        <tr>
    -                                            <td>TSTAT18</td>
    -                                            <td>Throttling has occurred</td>
    -                                        </tr>
    -                                        <tr>
    -                                            <td>TSTAT19</td>
    -                                            <td>Soft temperature limit has occurre</td>
    -                                        </tr>
    -                                    </table>
    -
    -                                </td>
    -                            </tr>
    -                        </tbody>
    -                    </table>
    -                    <table class="module-settings vtop">
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="module-pi-status.png" alt="PI Status Settings" />
    -                            </td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>Read Every</strong> - Reads the pi status every this number of seconds
    -                                    </li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -
    -                    </table>
    -
    -
    -                </details>
    -
    -            </div>
    -
    -
    -            <h2>Creating and using Masks</h2>
    -            <div>
    -                <p>Several of the modules make use of masks. Masks are used to 'hide' areas of the image to
    -                    prevent them creating issues in the modules. For example the star detection module can use a
    -                    mask to hide all areas of the image that may contain things that will confuse the star
    -                    detection typically local light pollution</p>
    -                <blockquote>Masks must be created in image editing software such as Gimp or Photoshop. There are no
    -                    tools available within AllSky to create masks</blockquote>
    -                <details>
    -                    <summary></summary>
    -
    -                    <p>Masks can also be used to 'clean' up the non image circle parts of the image.</p>
    -
    -                    <table class="module-settings vtop">
    -                        <tr>
    -                            <td>
    -                                <p>Consider the following image which is a raw image from the camera. The areas
    -                                    outside of the main image contain a lot of noise</p>
    -                            </td>
    -                            <td><img allsky="true" loading="lazy" src="mask-raw.png" alt="Raw Mask Image" />
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td>
    -                                <p>To clean this up we create a 'mask' The mask is a Black and White image. Any
    -                                    areas in white will be kept any areas in black will be masked. So creating a
    -                                    mask like this</p>
    -                            </td>
    -                            <td><img allsky="true" loading="lazy" src="mask-raw-mask.png" alt="Mask" /></td>
    -                        </tr>
    -                        <tr>
    -                            <td>
    -                                <p>After the mask has been applied the resulting image looks a lot cleaner</p>
    -                            </td>
    -                            <td><img allsky="true" loading="lazy" src="mask-raw-final.png" alt="Masked Image" />
    -                            </td>
    -                        </tr>
    -                    </table>
    -
    -                    <p>Masks are also used to mask areas of the image for some modules, such as the star count. This is
    -                        to prevent false positive. Consider the following example</p>
    -                    <table class="module-settings vtop">
    -                        <tr>
    -                            <td>
    -                                <p>Consider the following image which is a raw image from the camera. There is a lot of
    -                                    non sky in the image, houses trees etc. These 'artifacts' can cause some detection
    -                                    modules to get confused so by masking them they will not be included in any of the
    -                                    calculations</p>
    -                            </td>
    -                            <td><img allsky="true" loading="lazy" src="mask-stars-raw.png" alt="Raw Mask Image" />
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td>
    -                                <p>This mask was created in an external image editing program to mask out the areas of
    -                                    the image that are not sky</p>
    -                                <blockquote>Since this mask is being used to mask specific areas of the image if the
    -                                    camera is moved then the mask may have to be recreated</blockquote>
    -                            </td>
    -                            <td><img allsky="true" loading="lazy" src="mask-stars-mask.png" alt="Mask" /></td>
    -                        </tr>
    -                        <tr>
    -                            <td>
    -                                <p>After the mask has been applied the resulting image will allow the various modules to
    -                                    process the image</p>
    -                            </td>
    -                            <td><img allsky="true" loading="lazy" src="mask-stars-result.png" alt="Masked Image" />
    -                            </td>
    -                        </tr>
    -                    </table>
    -
    -
    -                    <blockquote>
    -                        <p>Some modules will apply the mask but it will not be used for the final image.
    -                            Examples of this are masks used to mask the image for star/meteor detection</p>
    -                    </blockquote>
    -                    <blockquote>
    -                        <p>Masks do not have to be circular, they can be any shape you like. In fact the masks
    -                            used for star and meteor detection are very likely to be a strange shape</p>
    -                    </blockquote>
    -                </details>
    -            </div>
    -
    -            <h2>GPIO</h2>
    -            <div>
    -                <p>Several of the modules require the selection of a GPIO pin on the pi. The module editor has
    -                    an inbuilt GPIO selector to help ease the process of selecting the required pin</p>
    -                <details>
    -                    <summary></summary>
    -                    <table class="module-settings vtop">
    -                        <tr>
    -                            <td>
    -                                <p>When clicking on the GPIO selection the GPIO selector is displayed allowing
    -                                    you to select the required pin, in the example GPIO27 is currently selected
    -                                </p>
    -                                <blockquote>Currently only the PI 3/4 GPIO pins are implemented</blockquote>
    -                            </td>
    -                            <td><img allsky="true" loading="lazy" src="gpio.png" alt="GPIO Selector" /></td>
    -                        </tr>
    -                    </table>
    -                </details>
    -            </div>
    -
    -
    -            <h2>Module Performance / Debugging</h2>
    -            <div>
    -                <p>Its important that modules do not take too much time to run as this will cause load issues on
    -                    the PI.</p>
    -                <details>
    -                    <summary></summary>
    -                    <p>After AllSky has taken an image the save image process is run in background, including
    -                        running the relevant module flow. Its important that this process finishes before the
    -                        next image is captured. If the module flow takes too long multiple save image process
    -                        will be running, this will start to cause performance issues on the PI</p>
    -                    <p>The best way to ensure this does not happen is to make sure that the module watchdog is
    -                        enabled in the <span class="managerName">Module Manager</span> Settings</p>
    -                    <blockquote><strong>TIP</strong> - When installing a new module ensure debug mode is enabled
    -                        in the <span class="managerName">Module Manager Settings and monitor how long the new module is taking and what
    -                        effect its having on the overall flow time</blockquote>
    -                    <h3>The Module Manager Debug Window</h3>
    -                    <table class="module-settings vtop">
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="Module-Manager-Debug.png"
    -                                    alt="Module Debug Window" /></td>
    -                            <td>
    -                                <p>The debug dialog shows each of the enabled modules, how long it took to run,
    -                                    the result of the last run and the total execution time for all modules</p>
    -                                <p>This information is useful if you find there are too many save image
    -                                    processes running for determining if any particular module is causing an
    -                                    issue</p>
    -                            </td>
    -                        </tr>
    -                    </table>
    -                    <h3>The AllSky debug log</h3>
    -                    <p>The main AllSky debug log will contain information from the module processor. The amount
    -                        of information logged will depend upon the main AllSky debug level. When the debug level
    -                        is 0 only critical module errors will be logged. Any other log level will display
    -                        verbose information as shown below.</p>
    -                    <blockquote>Periodic module output is logged to the allskyperiodic.log log file. This file
    -                        will be in the same location as the main AllSky log file, typically /var/log
    -                    </blockquote>
    -                    <div class="code">
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Loading config...<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Loading recipe...<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: ----------------------- Running
    -                        Module allsky_loadimage.py -----------------------<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Attempting to load
    -                        allsky_loadimage<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Image
    -                        /home/alex/allsky/tmp/image-20221023210636.jpg Loaded<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Module allsky_loadimage.py ran ok in
    -                        0.493601s<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: ----------------------- Running
    -                        Module allsky_pistatus.py -----------------------<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Attempting to load
    -                        allsky_pistatus<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Module allsky_pistatus.py ran ok in
    -                        0.041586s<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: ----------------------- Running
    -                        Module allsky_saveintermediateimage.py -----------------------<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Attempting to load
    -                        allsky_saveintermediateimage<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: /home/alex/allsky/images/20221023-clean<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Image
    -                        /home/alex/allsky/images/20221023-clean/image-20221023210636.jpg Saved<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Module
    -                        allsky_saveintermediateimage.py ran ok in 0.442361s<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: ----------------------- Running
    -                        Module allsky_clearsky.py -----------------------<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Attempting to load
    -                        allsky_clearsky<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Created star template. Radius - 6<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Sky is NOT clear. 5 Stars found,
    -                        clear limit is 40<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: MQTT disabled<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Module allsky_clearsky.py ran ok in
    -                        0.677157s<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: ----------------------- Running
    -                        Module allsky_starcount.py -----------------------<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Attempting to load
    -                        allsky_starcount<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Sky is not clear so ignoring
    -                        starcount<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Module allsky_starcount.py ran ok in
    -                        9.8e-05s<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: ----------------------- Running
    -                        Module allsky_meteor.py -----------------------<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Attempting to load allsky_meteor<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Sky is not clear so ignoring meteor
    -                        detection<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Module allsky_meteor.py ran ok in
    -                        6.8e-05sv<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: ----------------------- Running
    -                        Module allsky_dewheater.py -----------------------<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Attempting to load
    -                        allsky_dewheater<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Turning Heater on<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Temperature within limit temperature
    -                        11.13, limit 10, dewPoint 8.74<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Sensor SHT31 read. Temperature 11.13
    -                        Humidity 85.19 Dew Point 8.74 Heat Index -14.47<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Module allsky_dewheater.py ran ok in
    -                        0.040973s<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: ----------------------- Running
    -                        Module allsky_maskimage.py -----------------------<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Attempting to load
    -                        allsky_maskimage<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Module allsky_maskimage.py ran ok in
    -                        0.516113s<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: ----------------------- Running
    -                        Module allsky_cloud.py -----------------------<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Attempting to load allsky_cloud<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Cloud state - Cloudy 100.0. Sky Temp
    -                        10.370000000000005, Ambient 10.450000000000045<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Module allsky_cloud.py ran ok in
    -                        0.002392s<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: ----------------------- Running
    -                        Module allsky_export.py -----------------------<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Attempting to load allsky_export<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Allsky data exported to
    -                        /home/alex/allsky/tmp/allskydata.json<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Module allsky_export.py ran ok in
    -                        0.001127s<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: ----------------------- Running
    -                        Module allsky_overlay.py -----------------------<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Attempting to load allsky_overlay<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Config file set to
    -                        /home/alex/allsky/config/overlay.json<br>
    -                        Oct 23 21:08:15 allskytest allsky.sh[24948]: INFO: Loading Config took 0.00063 Seconds.
    -                        Elapsed Time 0.000662 Seconds.<br>
    -                        Oct 23 21:08:16 allskytest allsky.sh[24948]: INFO: Adding Text Fields took 0.255803
    -                        Seconds. Elapsed Time 0.685933 Seconds.<br>
    -                        Oct 23 21:08:16 allskytest allsky.sh[24948]: INFO: Adding Image Fields took 0.248668
    -                        Seconds. Elapsed Time 0.934638 Seconds.<br>
    -                        Oct 23 21:08:16 allskytest allsky.sh[24948]: INFO: Saving Final Image took 0.006398
    -                        Seconds. Elapsed Time 0.941075 Seconds.<br>
    -                        Oct 23 21:08:16 allskytest allsky.sh[24948]: INFO: Writing debug data took 4.5e-05
    -                        Seconds. Elapsed Time 0.941138 Seconds.<br>
    -                        Oct 23 21:08:16 allskytest allsky.sh[24948]: INFO: Debug information written to
    -                        /home/alex/allsky/tmp/overlaydebug.txt<br>
    -                        Oct 23 21:08:16 allskytest allsky.sh[24948]: INFO: Annotation Complete Elapsed Time
    -                        0.942793 Seconds.<br>
    -                        Oct 23 21:08:16 allskytest allsky.sh[24948]: INFO: Module allsky_overlay.py ran ok in
    -                        0.951637s<br>
    -                        Oct 23 21:08:16 allskytest allsky.sh[24948]: INFO: ----------------------- Running
    -                        Module allsky_savedetails.py -----------------------<br>
    -                        Oct 23 21:08:16 allskytest allsky.sh[24948]: INFO: Attempting to load
    -                        allsky_savedetails<br>
    -                        Oct 23 21:08:16 allskytest allsky.sh[24948]: INFO: Module allsky_savedetails.py ran ok
    -                        in 0.128938s<br>
    -                        Oct 23 21:08:16 allskytest allsky.sh[24948]: INFO: ----------------------- Running
    -                        Module allsky_saveimage.py -----------------------<br>
    -                        Oct 23 21:08:16 allskytest allsky.sh[24948]: INFO: Attempting to load
    -                        allsky_saveimage<br>
    -                        Oct 23 21:08:16 allskytest allsky.sh[24948]: INFO: Image
    -                        /home/alex/allsky/tmp/image-20221023210636.jpg Saved, quality 100<br>
    -                        Oct 23 21:08:16 allskytest allsky.sh[24948]: INFO: Module allsky_saveimage.py ran ok in
    -                        0.388242s<br>
    -
    -                    </div>
    -                </details>
    -            </div>
    -
    -            <h2>Developing Custom Modules</h2>
    -            <div>
    -                <p>The module system is designed to be extended by developing custom modules</p>
    -                <details>
    -                    <summary></summary>
    -                    <h3>Python Versions</h3>
    -                    <p>Modules are developed in <a href="https://www.python.org/" target="_blank">Python</a> and
    -                        should use the latest version available for the Pi. Its should be noted that whilst
    -                        multiple versions of Python can be installed on a Pi its best just to use the version
    -                        that ships with the OS. Generally that may mean reinstalling your AllSky installation to
    -                        get the latest version of python. Running multiple versions of python is beyond the
    -                        scope of this documentation </p>
    -                    <h3>Contributing a module</h3>
    -                    <p>There is a central GitHub repository that you can contribute your modules to. You do not have to
    -                        do this but over time we wouls like to build this into a comprehensive library of available
    -                        modules.</p>
    -                    <p>If you wish to contribute your module then please follow these instructions</p>
    -                    <p>
    -                    <ul>
    -                        <li>Create a fork of the User Modules repository <a
    -                                href="https://github.com/Alex-developer/allsky-modules"
    -                                target="_blank">https://github.com/Alex-developer/allsky-modules</a></li>
    -                        <li>Create a new branch in your forked repository, call the branch the name of your module</li>
    -                        <li>Develop your module and commit it to the branch</li>
    -                        <li>Create a pull request into the main repo from your branch</li>
    -                        <li>The module will be checked and if all is ok will be merged into the main repository</li>
    -                        <li>If you need to make changes to the module after its been merged then refork the main repo
    -                            create a branch from the master branch named as your module then commit ad PR to main master
    -                        </li>
    -                    </ul>
    -                    </p>
    -                    <p>The structure of a module is important so please use the following folder/file structure</p>
    -                    <p><strong>allsky_MODULENAME</strong></p>
    -                    <p>-- <strong>allsky_MODULENAME.py</strong> <span>The main modules code</span></p>
    -                    <p>-- <strong>requirements.txt</strong> <span>Any python packages required by the plugin, will be
    -                            installed with pip3. If you do not need any additional packages do not include this
    -                            file</span></p>
    -                    <p>-- <strong>packages.txt</strong> <span>Any aditional libraries required by the module, will be
    -                            installed using apt. If you do not need any additional packages do not include this
    -                            file</span></p>
    -                    <p>-- <strong>README.md</strong> <span>Markdown file with any special instructions required for the
    -                            module. If there are none do not include this file</span></p>
    -                    <blockquote>When specifying python libraries DO NOT include numpy in ANY requirements.txt file as
    -                        changing the version could cause issues with AllSky</blockquote>
    -                    <h3>Anatomy Of A Module</h3>
    -                    <p>A module consists of two key parts</p>
    -                    <ul>
    -                        <li><strong>The metadata variable</strong> - This variable defines everything the module
    -                            manager needs to run the module. This includes basic information about the module
    -                            and the configuration options it requires</li>
    -                        <li><strong>The module entry point</strong> - The main function that is called by the
    -                            <span class="managerName">Module Manager</span></li>
    -                    </ul>
    -                    <p>Every module MUST import the allsky_shared module. This module is used to pass data from
    -                        the flow processor to each module</p>
    -
    -                    <h4>The Meta Data Variable</h4>
    -                    <blockquote>Please refer to the BoilerPlate example module for more details of the variable.
    -                        The Boiler Plate example includes all of the available options</blockquote>
    -
    -                    <table class="vtop">
    -                        <tr>
    -                            <th>Field</th>
    -                            <th>Type</th>
    -                            <th>Mandatory</th>
    -                            <th>Description</th>
    -                        </tr>
    -                        <tr>
    -                            <td>name</td>
    -                            <td>String</td>
    -                            <td>Yes</td>
    -                            <td>The name of the module. This field is displayed as the heading for a module when
    -                                its displayed in the module manager</td>
    -                        </tr>
    -                        <tr>
    -                            <td>description</td>
    -                            <td>String</td>
    -                            <td>Yes</td>
    -                            <td>The description of the module. This field is displayed in the module manager
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td>module</td>
    -                            <td>String</td>
    -                            <td>Yes</td>
    -                            <td>The module name. <strong>MUST</strong> be in the format allsky_{module name}
    -                                i.e. allsky_boilerplate</td>
    -                        </tr>
    -                        <tr>
    -                            <td>version</td>
    -                            <td>String</td>
    -                            <td>No</td>
    -                            <td>The version of the module. If this field is not present then the version of the
    -                                main AllSky software will be used</td>
    -                        </tr>
    -                        <tr>
    -                            <td>enabled</td>
    -                            <td>String</td>
    -                            <td>No</td>
    -                            <td>'true' or 'false'. If set to true the module is enabled, handy for setting its
    -                                initial state. <strong>NOTE:</strong> If the option to auto enable modules is
    -                                enabled in the module manager then this will take priority over this value</td>
    -                        </tr>
    -                        <tr>
    -                            <td>events</td>
    -                            <td>Dictionary</td>
    -                            <td>Yes</td>
    -                            <td>A list of the flows the module should be displayed in. Valid flows are
    -                                <ul>
    -                                    <li><strong>day</strong> - Tells the module manager to display the module in
    -                                        the daytime flows</li>
    -                                    <li><strong>night</strong> - Tells the module manager to display the module
    -                                        in the nighttime flows</li>
    -                                    <li><strong>daynight</strong> - Tells the module manager to display the
    -                                        module in the day to night transition flows</li>
    -                                    <li><strong>nightday</strong> - Tells the module manager to display the
    -                                        module in the night to day transition flows</li>
    -                                    <li><strong>periodic</strong> - Tells the module manager to display the
    -                                        module in the periodic flows</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td>experimental</td>
    -                            <td>String</td>
    -                            <td>No</td>
    -                            <td>'true' or 'false'. If set to true a warning is displayed in the module manager
    -                                indicating the module is experimental</td>
    -                        </tr>
    -                        <tr>
    -                            <td>arguments</td>
    -                            <td>Dictionary</td>
    -                            <td>No</td>
    -                            <td>A list of the values that will be passed to the main processing methof of the
    -                                module in the params array. This exists to allow you to specify defaults for the
    -                                fields. Definitions for the fields are defined in the argumentdetails field
    -                                below</td>
    -                        </tr>
    -                        <tr>
    -                            <td>argumentdetails</td>
    -                            <td>Dictionary</td>
    -                            <td>No</td>
    -                            <td>Dictionary of definitions for each of the arguments</td>
    -                        </tr>
    -                    </table>
    -
    -                    <h5>The argumentdetails Dictionary</h5>
    -                    <p>A module may require that the user can set values for its paramaters. This is implemented
    -                        via the argumens and argumentdetails sections. If there are any argumentdetails present
    -                        then a settings option will be displayed in the module manager. Clicking on the settings
    -                        option will display a dialog allowing the modules settings to be changed. The dialog is
    -                        created automatically from the options defined in the argumentdetails section</p>
    -                    <table class="vtop">
    -                        <tr>
    -                            <th>Field</th>
    -                            <th>Type</th>
    -                            <th>Mandatory</th>
    -                            <th>Description</th>
    -                        </tr>
    -                        <tr>
    -                            <td>required</td>
    -                            <td>String</td>
    -                            <td>Yes</td>
    -                            <td>'true' or 'false'. If set to true the field is required. <strong>NOTE:</strong>
    -                                This is not currently implemented but will be in a future release</td>
    -                        </tr>
    -                        <tr>
    -                            <td>description</td>
    -                            <td>String</td>
    -                            <td>Yes</td>
    -                            <td>This is the label for the field in the module settings dialog</td>
    -                        </tr>
    -                        <tr>
    -                            <td>help</td>
    -                            <td>String</td>
    -                            <td>No</td>
    -                            <td>This is the help text for the field in the module settings dialog</td>
    -                        </tr>
    -                        <tr>
    -                            <td>tab</td>
    -                            <td>String</td>
    -                            <td>No</td>
    -                            <td>The tab to display the field in. If left blank the field will be displayed on
    -                                the 'Home' tab. This is handy for grouping fields into seperate tabs</td>
    -                        </tr>
    -                        <tr>
    -                            <td>type</td>
    -                            <td>Dictionary</td>
    -                            <td>No</td>
    -                            <td>A Dictionary defining the type of field. The key is the name of the field, the
    -                                same name as used in the arguments section. If no type is defined the field is
    -                                assumed to be a text field
    -                            </td>
    -                        </tr>
    -                    </table>
    -                    <h5>Available field types</h5>
    -                    <table class="vtop">
    -                        <tr>
    -                            <th>fieldtype</th>
    -                            <th>Description</th>
    -                            <th>Additional paramaters</th>
    -                        </tr>
    -                        <tr>
    -                            <td colspan="3">If blank the field will be assumed to be a text entry field</td>
    -                        </tr>
    -                        <tr>
    -                            <td>select</td>
    -                            <td>A drop down list from which the user can select an option</td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>values</strong> A comma separated list of values for the drop
    -                                        down. The default value for the drop down list is specified in the
    -                                        arguments section</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td>checkbox</td>
    -                            <td>A checkbox allowing the entry of a true or false value</td>
    -                            <td>None</td>
    -                        </tr>
    -                        <tr>
    -                            <td>spinner</td>
    -                            <td>A numerical entry field with up down controls to alter the value</td>
    -                            <td>
    -                                <ul>
    -                                    <li><strong>min</strong> The minimum value for the field</li>
    -                                    <li><strong>max</strong> The maximum value for the field</li>
    -                                    <li><strong>step</strong> The step for the field</li>
    -                                </ul>
    -                            </td>
    -                        </tr>
    -                        <tr>
    -                            <td>gpio</td>
    -                            <td>Displays a dialog allowing the user to select a GPIO pin on the Pi. This field
    -                                type is useful when developing a module that requries a user to select a GPIO
    -                                pin</td>
    -                            <td>None</td>
    -                        </tr>
    -                        <tr>
    -                            <td>image</td>
    -                            <td>Displays a dialog allowing the user to select/upload an image. This is useful
    -                                where a module may require the user to select a mask</td>
    -                            <td>None</td>
    -                        </tr>
    -                        <tr>
    -                            <td>roi</td>
    -                            <td>Displays a dialog allowing the user to select a Region Of Interest (ROI) from
    -                                the captured image</td>
    -                            <td>None</td>
    -                        </tr>
    -                    </table>
    -
    -                    <h5>Complete metadata example</h5>
    -                    <table class="vtop">
    -                        <tr>
    -                            <td><img allsky="true" loading="lazy" src="metadata.png"></td>
    -                            <td>
    -                                <p>This is a full example of all of the possible options available in the
    -                                    metaData dictionary</p>
    -                                <p>This example can be found in the boilerplate module in the additional modules
    -                                    repository on GitHub</p>
    -                            </td>
    -                        </tr>
    -                    </table>
    -
    -                    <h4>The AllSky Shared module</h4>
    -                    <p>To improve the performance of modules a common shared module is used. This shared module
    -                        allsky_shared contains both helper functions and data from the main capture process. The
    -                        module exists so that each individual module does not have to perform length taks like
    -                        loading an image</p>
    -
    -                    <h5>AllSky Shared module data</h5>
    -                    <table>
    -                        <tr>
    -                            <th>Variable</th>
    -                            <th>Description</th>
    -                        </tr>
    -                        <tr>
    -                            <td>args</td>
    -                            <td>The arguments passed to the module processor (not normally needed)</td>
    -                        </tr>
    -                        <tr>
    -                            <td>LOGLEVEL</td>
    -                            <td>The allsky debug level (not normally ndded)</td>
    -                        </tr>
    -                        <tr>
    -                            <td>CURRENTIMAGEPATH</td>
    -                            <td>The full path to the current image (not normally needed, not available in
    -                                daynight, nightday or periodic)</td>
    -                        </tr>
    -                        <tr>
    -                            <td>TOD</td>
    -                            <td>The time of day not available in daynight, nightday or periodic)</td>
    -                        </tr>
    -                        <tr>
    -                            <td>fullFilename</td>
    -                            <td>The final image filename i.e. image.jpg not available in daynight, nightday or
    -                                periodic)</td>
    -                        </tr>
    -                        <tr>
    -                            <td>image</td>
    -                            <td>a numpy array of the current image - Use this for any processing of the captured
    -                                image. <strong>DO NOT</strong> attempt to load the image from disk within a
    -                                module as this will have a severe performance impact on the module. Not
    -                                available in daynight, nightday or periodic)</td>
    -                        </tr>
    -                    </table>
    -
    -                    <h5>AllSky Shared module helpers</h5>
    -                    <table class="vtop">
    -                        <tr>
    -                            <th>Function</th>
    -                            <th>Description</th>
    -                        </tr>
    -                        <tr>
    -                            <td>log(level, message)</td>
    -                            <td>Logs an entry to the allsky log file if the debug level is above 'level'. Errors
    -                                are always logged. When a module needs to write to a log it should use this
    -                                function rather than write to any log files directly. THis function will ensure
    -                                that the log message appears in the main AllSky log file</td>
    -                        </tr>
    -                        <tr>
    -                            <td>getEnvironmentVariable(name, fatal=False, error='')</td>
    -                            <td>Gets an environment variable, can terminate if needed by setting the fatal
    -                                variable and an error code</td>
    -                        </tr>
    -                        <tr>
    -                            <td>var_dump(variable)</td>
    -                            <td>Pretty dump of a variable. This is handy for debugging modules. The output will
    -                                appear in the main AllSky log file.</td>
    -                        </tr>
    -                        <tr>
    -                            <td>getSetting(settingName)</td>
    -                            <td>Gets a setting from the camera settings file</td>
    -                        </tr>
    -                        <tr>
    -                            <td>writeDebugImage(module, fileName, image)</td>
    -                            <td>Writes a debug image to the $ALLSKY/tmp/debug/{module} folder. This can be
    -                                useful when debugging a module to check the output at various stages</td>
    -                        </tr>
    -                        <tr>
    -                            <td>startModuleDebug(module)</td>
    -                            <td>Creates the debug directories for a module</td>
    -                        </tr>
    -                        <tr>
    -                            <td>convertPath(path)</td>
    -                            <td>Replaces allsky variables in a string, this is useful if a module enters a
    -                                directory based upon any of the AllSky variables. So for example
    -                                '${ALLSKY_TMP}/allskydata.json' will have the allsky tmp avriable replaced with
    -                                its real path. This function can be used to replace any of the allsky variables
    -                                in a string</td>
    -                        </tr>
    -                        <tr>
    -                            <td>checkAndCreatePath(filePath)</td>
    -                            <td>Checks if the passed file exists and if not creates it</td>
    -                        </tr>
    -                        <tr>
    -                            <td>checkAndCreateDirectory(filePath)</td>
    -                            <td>Checks if the passed directory exists and if not creates it</td>
    -                        </tr>
    -                        <tr>
    -                            <td>raining()</td>
    -                            <td>Only available if the rain module is being used - Returns a boolean flags to
    -                                indicate the rain state</td>
    -                        </tr>
    -                        <tr>
    -                            <td>convertLatLon(input)</td>
    -                            <td>Converts 52.2N to 52.2 i.e converts to decimal</td>
    -                        </tr>
    -                        <tr>
    -                            <td>setLastRun(module)</td>
    -                            <td>Sets the last run time for a module. Useful for where a module only needs to run
    -                                periodically</td>
    -                        </tr>
    -                        <tr>
    -                            <td>shouldRun(module, period)</td>
    -                            <td>Determines if a module should run based on the period</td>
    -                        </tr>
    -                        <tr>
    -                            <td>dbAdd(key, value)</td>
    -                            <td>Adds the key/value pair to the internal database</td>
    -                        </tr>
    -                        <tr>
    -                            <td>dbUpdate(key, value)</td>
    -                            <td>Updates the key/value pair to the internal database</td>
    -                        </tr>
    -                        <tr>
    -                            <td>isFileWriteable(fileName)</td>
    -                            <td>Determines of the file is writeable</td>
    -                        </tr>
    -                        <tr>
    -                            <td>isFileReadable(fileName)</td>
    -                            <td>Determines of the file is readable</td>
    -                        </tr>
    -                        <tr>
    -                            <td>saveExtraData(fileName, extraData)</td>
    -                            <td>Saves the extraData in fileName.</td>
    -                        </tr>
    -                        <tr>
    -                            <td>deleteExtraData(fileName)</td>
    -                            <td>Deletes the extra data fileName.</td>
    -                        </tr>
    -                        <tr>
    -                            <td>getGPIOPin(pin)</td>
    -                            <td>Returns the board.Pin from the passed in pin (int)</td>
    -                        </tr>
    -                    </table>
    -                </details>
    -            </div>
    -
    -
    -
    -        </div><!-- Layout-main -->
    -    </div><!-- Layout ... -->
    +	<div w3-include-html="/documentation/pageHeader.html" id="pageHeader"></div>
    +	<div class="Layout">
    +		<div class="Layout-sidebar" w3-include-html="/documentation/sidebar.html" id="sidebar"></div>
    +		<div class="Layout-main markdown-body" id="mainContents">
    +
    +			<p><strong>Modules</strong> can be used to enhance images created by Allsky.
    +				Different modules perform different tasks,
    +				and the <span class="managerName">Module Manager</span>
    +				allows you to control which modules run and when.
    +			</p>
    +			<p>There are two types of modules:</p>
    +			<ol>
    +				<li><strong>Allsky Modules</strong> are developed and maintained
    +					by the Allsky team and are installed with Allsky.</li>
    +				<li><strong>User Modules</strong> are developed and maintained
    +					by other people and can optionally be installed
    +					and deleted if no longer required.
    +					<blockquote class="warning">
    +						User Modules should only be installed from the official
    +						<a external="true" href="https://github.com/AllskyTeam/allsky-modules">
    +						user-modules</a> repository using the directions listed there.
    +						Installing from other source can be dangerous.
    +					</blockquote>
    +					</li>
    +			</ol>
    +
    +			Warning:
    +			<ul>
    +				<li>Some modules are shown as <strong>experimental</strong>.
    +					Before using these modules you should ensure that you are proficient
    +					in analysing Linux log files.
    +					Experimental modules can be unstable and as such may "break" Allsky.</li>
    +				<li>Some modules are designed to operate with external hardware,
    +					typically connected to the Pi's GPIO pins.
    +					Before attempting to use any of these modules make sure
    +					you have an understanding of interfacing hardware to the PI's GPIO.</li>
    +			</ul>
    +
    +			<p>Don't be discouraged by the above warnings -
    +				the module system is very flexible and allows you to
    +				customise Allsky without having to change any Allsky code.</p>
    +
    +
    +			<h1>Module Flows</h1>
    +			<div>
    +				<p>Modules are run within <strong>Flows</strong>
    +					which determine when the modules run.
    +					Each flow can have a different list of modules that are run
    +					and the order in which they are run.
    +					The different flows are listed below.
    +				</p>
    +				<details>
    +					<summary></summary>
    +					<p>The flows in which modules run are:</p>
    +					<ul class="minimalPadding">
    +						<li><strong>Daytime Capture</strong> - Modules in this flow
    +							are run after every daytime image is captured and initially saved.
    +						</li>
    +						<li><strong>Nighttime Capture</strong> - Modules in this flow
    +							are run after every nighttime image is captured and saved.
    +						</li>
    +						<li><strong>Day to Night Transition</strong> - Modules in this flow
    +							are run when Allsky switches from Daytime to Nighttime capturing per the
    +							<span class="WebUISetting">Latitude</span>,
    +							<span class="WebUISetting">Longitude</span>, and
    +							<span class="WebUISetting">Angle</span>
    +							you've defined in the WebUI.
    +						</li>
    +						<li><strong>Night to Day Transition</strong> - Modules in this flow
    +							are run when Allsky switches from Nighttime to Daytime capturing.
    +						</li>
    +						<li><strong>Periodic jobs</strong> - Modules in this flow
    +							are run periodically and are not related to image capture.
    +							Each module defines how often it runs.</li>
    +					</ul>
    +					<p>The diagram below shows each stage of the capture process and how modules fit into it.
    +					</p>
    +
    +					<img allsky="true" loading="lazy" src="Flows.png" alt="Flows" class="imgCenter" />
    +				</details>
    +			</div>
    +
    +
    +			<h1>The <span class="managerName">Module Manager</span></h1>
    +			<div>
    +				<p>Modules are managed via the <span class="managerName">Module Manager</span>.</p>
    +				<details>
    +					<summary></summary>
    +					<img allsky="true" class="imgCenter" loading="lazy"
    +						src="Module-Manager.png" alt="Module Manager" />
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th>Annotation</th>
    +								<th>Icon</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td class="WebUISetting">Save</td>
    +								<td><i class="fa fa-save fa-lg"></i></td>
    +								<td>This is enabled when any changes are made.</td>
    +							</tr>
    +							<tr>
    +								<td class="WebUISetting">Upload</td>
    +								<td><i class="fa-solid fa-upload"></i></td>
    +								<td>This allows you to upload a zip file containing
    +									a new or updated module.
    +									Please see the section on uploading a modules for
    +									more details on how to use this feature.</td>
    +							</tr>
    +							<tr>
    +								<td class="WebUISetting">Flow</td>
    +								<td></td>
    +								<td>The flow you wish to manage.
    +									If you have unsaved changes from the current
    +									flow you will be prompted to save them before switching</td>
    +							</tr>
    +							<tr>
    +								<td class="WebUISetting">Settings</td>
    +								<td><i class="fa-solid fa-gear"></i></td>
    +								<td>Displays the settings dialog for the
    +									<span class="managerName">Module Manager</span>.</td>
    +							</tr>
    +							<tr>
    +								<td class="WebUISetting">Debug</td>
    +								<td><i class="fa-solid fa-bug"></i></td>
    +								<td>Displays the debug data for modules.
    +									This icon is only visible if it's been
    +									enabled in the Module Options.</td>
    +							</tr>
    +							<tr>
    +								<td class="WebUISetting">Reset</td>
    +								<td><i class="fa-solid fa-rotate-right"></i></td>
    +								<td>Resets the selected flow to the installation default.</td>
    +							</tr>
    +							<tr>
    +								<td class="WebUISetting">Restore</td>
    +								<td><i class="fa-solid fa-upload"></i></td>
    +								<td>Restores the flow to the last backup.
    +									This option is only be visible if there is a
    +									restore point available.</td>
    +							</tr>
    +
    +
    +						</tbody>
    +					</table>
    +
    +					<h2>Selecting a flow</h2>
    +					<div>
    +						<p>From the drop down list select the flow that you wish to amend.
    +							If there are unsaved changes for the current flow then
    +							you will be prompted to save them before switching to a new flow.
    +							The current day/night flow will automatically be selected depending
    +							upon the current time of day.
    +						</p>
    +					</div>
    +
    +					<h2>Uploading a module</h2>
    +					<blockquote class="warning">Please only upload modules from trusted sources.
    +						All modules uploaded are done so at your own risk.</blockquote>
    +					<p>To upload a module it must be in a zip file with the same name as the module.
    +						For example, if you have a module called
    +						<span class="fileName">allsk_test.py</span> the zip file must
    +						be called <span class="fileName">allsky_test.zip</span>.
    +					</p>
    +
    +					<h2>Enabling a module</h2>
    +					<div>
    +						<p>To enable a module drag it from the <strong>Available Modules</strong>
    +							column to the <strong>Selected Modules</strong> column
    +							and select the "Enabled" checkbox.
    +							The module will become active after the flow has been saved.
    +							You can set modules to be automatically enabled when dragging
    +							them to the <strong>Selected Modules</strong> column in the
    +							<span class="editorName">Module Editor</span> Module Options.</p>
    +						<p>Most modules require some configuration after they have
    +							been moved to the <strong>Selected Modules</strong> column.
    +							Clicking the
    +							<span class="btn btn-primary btn-not-real btn-small">Settings</span>
    +							button will display any configuration options for the module.</p>
    +					</div>
    +
    +					<h2>Disabling a Module</h2>
    +					<div>
    +						<p>There are two ways to disable a module:</p>
    +						<ul class="minimalPadding">
    +							<li>Drag the module to the <strong>Available Modules</strong> column -
    +								this will disable the module and lose any settings for it.</li>
    +							<li>Uncheck the "Enabled" checkbox -
    +								this will disable the module but retain any settings.
    +								This is the preferred method if you just wish
    +								to temporarily disable a module.</li>
    +						</ul>
    +
    +						<h3>Setting the Module execution order</h3>
    +						<p>The modules are run in the order they appear in the
    +							<strong>Selected Modules</strong> column.
    +							To change the order simply drag the modules up or down the list.
    +							Modules
    +							<span style="color: white; background-color: #DF0000;">
    +								highlighted in red</span>
    +							can not be moved.</p>
    +					</div>
    +
    +					<h2><span class="managerName">Module Manager</span> Settings</h2>
    +					<div>
    +						<table class="module-settings vtop">
    +							<tr>
    +								<td><img allsky="true" loading="lazy" alt="Module manager Settings"
    +										src="module-manager-settings.png" /></td>
    +							</tr>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li><strong>Auto Enable</strong> -
    +											Automatically enables modules when added to the
    +											<strong>Selected Modules</strong> column.</li>
    +										<li><strong>Debug Mode</strong> -
    +											When enabled the debug option
    +											<i class="fa-solid fa-bug"></i>
    +											is available in the toolbar which will show
    +											the results and execution time for each module.</li>
    +										<li><strong>Periodic Timer</strong> -
    +											How frequently, in seconds,
    +											the <strong>Periodic jobs</strong> flow is run.</li>
    +									</ul>
    +								</td>
    +							</tr>
    +						</table>
    +					</div>
    +				</details>
    +			</div>
    +
    +
    +			<h1 id="Modules">Modules</h1>
    +			<div>
    +				<p>Modules form the heart of the module manager and there is a
    +				wide variety available.
    +				In this section each module is described in details along with all
    +				of the available settings.</p>
    +
    +				<h2>Allsky Modules</h2>
    +				<details>
    +					<summary></summary>
    +					<p>The Allsky Modules are installed along with the main Allsky installer.
    +						The available modules are:</p>
    +
    +					<h3>Load Image module</h3>
    +					<details sub><summary></summary>
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th class="nowrap">Available in Flows</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li>Daytime Capture</li>
    +										<li>Nighttime Capture</li>
    +									</ul>
    +								</td>
    +								<td>
    +									<p>Loads the last image taken by Allsky.
    +										This is always the first module that is run during
    +										the Daytime and Nighttime Capture flows to ensure that
    +										the image is available to all subsequent modules.
    +										It is therefore not possible to move or remove it.</p>
    +									<p>This module is enabled by default so there
    +										is no need to install it.</p>
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +					<hr class="separatorSmall">
    +					</details>
    +
    +					<h3>Save Image module</h3>
    +					<details sub><summary></summary>
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th class="nowrap">Available in Flows</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li>Daytime Capture</li>
    +										<li>Nighttime Capture</li>
    +									</ul>
    +								</td>
    +								<td>
    +									<p>Saves the final image after all of the modules have run.
    +										This is always the last module run
    +										so it is not possible to move or remove it.</p>
    +									<p>This module is enabled by default so there
    +										is no need to install it.</p>
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +					<hr class="separatorSmall">
    +					</details>
    +
    +					<h3>Mask Image module</h3>
    +					<details sub><summary></summary>
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th class="nowrap">Available in Flows</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li>Daytime Capture</li>
    +										<li>Nighttime Capture</li>
    +									</ul>
    +								</td>
    +								<td>
    +									<p>This applies a mask to the image.
    +										This can be useful if there are artefacts outside
    +										of the image created by the lens that you wish to remove.
    +										Create a mask with pure white in the areas
    +										you wish to <strong>keep</strong> and black in
    +										the areas you wish to be black.
    +										See the section on creating and using masks below.</p>
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +					<hr class="separatorSmall">
    +					</details>
    +
    +					<h3>Clear Sky module</h3>
    +					<details sub><summary></summary>
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th class="nowrap">Available in Flows</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li>Nighttime Capture</li>
    +									</ul>
    +								</td>
    +								<td>
    +									<p>This attempts to determine if the sky is clear.
    +										It does this by counting the stars in a
    +										Region of Interest (ROI) and if this is
    +										above a threshold the sky is assumed to be clear.
    +										The module's settings allow you to specify the
    +										ROI on the image and the
    +										parameters for detecting stars.
    +										If required the current calculated
    +										sky state can be sent to an MQTT broker.
    +										Certain other modules can
    +										also use the results of this module.
    +										For example the <span class="moduleName">Star Count</span>
    +										module can be set to only run if the sky is clear,
    +										as determined by this module.
    +										It is recommended to run this module early in the flow,
    +										generally after the
    +										<span class="moduleName">Mask Image</span> module.</p>
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Clearksy Settings"
    +								src="module-clearksy-settings-home.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>Region Of Interest</strong> -
    +										The ROI of the image to use for clear sky detection.</li>
    +									<li><strong>Fallback %</strong> -
    +										If no ROI is specified then this %
    +										around the center of the image will be used.</li>
    +									<li><strong>Clear Sky</strong> -
    +										The sky is assumed clear if more
    +										than this number of stars are found within the ROI.</li>
    +									<li><strong>Detection Threshold</strong> -
    +										Lowering this value will detect more stars
    +										and possibly false positives.
    +										Increasing it will detect fewer stars.</li>
    +									<li><strong>Distance Threshold</strong> -
    +										Any stars fond within this number of pixels
    +										of another star will only count as a single star.
    +										Reducing this value will increase the number
    +										of detected stars but
    +										also increase the number of false positives found.</li>
    +									<li><strong>Star Template Size</strong> -
    +										The size of a "standard" star.
    +										Reducing this value will detect more stars
    +										and false positives.</li>
    +									<li><strong>Mask Path</strong> -
    +										The path name to the mask that is applied to the image
    +										before any star detection.
    +										This is useful to exclude areas of the
    +										image that you do not want examined for stars.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Clearksy Settings"
    +								src="module-clearksy-settings-debug.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<p>These options are for debugging and should not be used
    +									for normal operation:</p>
    +								<ul>
    +									<li><strong>Annotate Stars</strong> -
    +										Draws a circle around each detected star.
    +										This is useful when tuning the detection values
    +										to see what is considered a star.</li>
    +									<li><strong>Enable Debug Mode</strong> -
    +										When selected an image is saved into the
    +										<span class="fileName">~allsky/tmp/debug</span>
    +										folder after each stage of the detection process.</li>
    +									<li><strong>Debug Image</strong> -
    +										If an image is specified then this is used
    +										rather than the last image taken by Allsky.
    +										This is useful for configuring
    +										the star detection during the day by using
    +										a previously captured image.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Clearksy Settings"
    +								src="module-clearksy-settings-mqtt.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<blockquote>This is an advanced option and requires an MQTT broker.
    +									The setup and operation of
    +									a broker is beyond the scope of this document.</blockquote>
    +								<ul>
    +									<li><strong>Enable MQTT</strong> -
    +										Enables publishing the Sky State to the MQTT Broker.</li>
    +									<li><strong>MQTT Broker address</strong> -
    +										The FQDN or IP address of the broker.</li>
    +									<li><strong>MQTT Broker port</strong> -
    +										The port number of the Broker.</li>
    +									<li><strong>MQTT Username</strong> -
    +										The username to login to the Broker.</li>
    +									<li><strong>MQTT Password</strong> -
    +										The password to login to the Broker.</li>
    +									<li><strong>MQTT Topic</strong> -
    +										The topic to post the Sky State to.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +					<hr class="separatorSmall">
    +					</details>
    +
    +					<h3>Star Count module</h3>
    +					<details sub><summary></summary>
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th class="nowrap">Available in Flows</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li>Nighttime Capture</li>
    +									</ul>
    +								</td>
    +								<td>
    +									<p>This counts stars in the captured Allsky Image.</p>
    +									<p>Stars are counted by using a template that looks
    +										like a star and attempts to locate anything in the image
    +										that looks like it.
    +										This works well when the image is well exposed and
    +										masked to remove any areas that do not contain
    +										stars (e.g., buildings, trees, etc.)</p>
    +									<p>You will need to experiment with the detection
    +										values to get the best results.
    +										Annotating the stars in the image can be very helpful.
    +										The Moon can also cause issues.
    +<!-- Uncomment and modify when these changes are implemented:
    +										A future version of this module will automatically
    +										mask the Moon or any other bright areas and also
    +										allow you to disable the module when the Moon
    +										meets certain critera
    +										such as over a certain % brightness and elevation.
    +-->
    +										</p>
    +									<blockquote>This module is experimental and as such
    +										may produce erroneous results.</blockquote>
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Starcount Settings"
    +								src="module-starcount-settings-home.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>Detection Threshold</strong> -
    +										Lowering this value will
    +										detect more stars, possibly false positives.
    +										Increasing it will detect fewer stars.</li>
    +									<li><strong>Distance Threshold</strong> -
    +										Any stars fond within this number of pixels of
    +										another star will only count as a single star.
    +										Reducing this value will increase the number
    +										of detected stars but
    +										also increase the number of false positives found.</li>
    +									<li><strong>Star Template size</strong> -
    +										The size of a "standard" star.
    +										reducing this value will detect more stars
    +										and false positives.</li>
    +									<li><strong>Mask Path</strong> -
    +										The path name to the mask that is applied to the image
    +										before any star detection.
    +										This is useful to exclude areas of the
    +										image that you do not want examined for stars.</li>
    +									<li><strong>Use Clear Sky</strong> -
    +										If the 'Clear Sky' module is
    +										running then its results will be used to
    +										determine if stars should be counted.
    +										If the sky is not clear then no attempt will be made to
    +										count stars.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Starcount Settings"
    +								src="module-starcount-settings-debug.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<p>These options are for debugging and should not be used
    +									for normal operation:</p>
    +								<ul>
    +									<li><strong>Annotate Stars</strong> -
    +										Draws a circle around each detected star.
    +										This is useful when tuning the detection values.</li>
    +									<li><strong>Enable debug mode</strong> -
    +										When selected an image is saved into the
    +										<span class="fileName">~/allsky/tmp/debug</span>
    +										folder after each stage of the detection process.</li>
    +									<li><strong>Debug Image</strong> -
    +										If an image is specified then this is
    +										used rather than the last image taken by Allsky.
    +										This is useful for configuring the star detection
    +										during the day by using a previosuly captured image.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +					<hr class="separatorSmall">
    +					</details>
    +
    +					<h3>Meteor Detection module</h3>
    +					<details sub><summary></summary>
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th class="nowrap">Available in Flows</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li>Nighttime Capture</li>
    +									</ul>
    +								</td>
    +								<td>
    +									<p>This attempts to detect any meteors in the image.
    +										The detection method looks for hard edges in
    +										an image and thus is susceptible to
    +										false positives if the image is not masked
    +										to hide anything that isn't sky.</p>
    +									<p>You will need to experiment with the detection
    +										values to get the best results.
    +										Annotating the meteors in the image can be very helpful.
    +										The Moon can also cause issues.
    +<!-- Uncomment and modify when these changes are implemented:
    +										A future version of this module will automatically
    +										mask the Moon or any other bright areas and also
    +										allow you to disable the module when the Moon
    +										meets certain critera
    +										such as over a certain % brightness and elevation.
    +-->
    +									<blockquote>This module is experimental and as such
    +										may produce erroneous results.</blockquote>
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Meteorcount Settings"
    +								src="module-meteor-settings-home.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>Mask Path</strong> -
    +										The path name to the mask that is applied to the image
    +										before any star detection.
    +										This is useful to exclude areas of the
    +										image that you do not want examined for stars.</li>
    +									<li><strong>Minimum Length</strong> -
    +										Any streaks longer than this number of
    +										pixels will be considered a meteor.</li>
    +									<li><strong>Use Clear Sky</strong> -
    +										If the <strong>Clear Sky</strong> module is
    +										running then its results will be used to
    +										determine if meteors should be detected.
    +										If the sky is not clear then no meteor detection will
    +										be attempted.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Meteorcount Settings"
    +								src="module-meteor-settings-debug.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<p>These options are for debugging and should not be used
    +									for normal operation:</p>
    +								<ul>
    +									<li><strong>Annotate Meteors</strong> -
    +										Any detected meteors will be
    +										highlighted in the image.</li>
    +									<li><strong>Enable debug mode</strong> -
    +										When selected an image is saved into the
    +										<span class="fileName">~/allsky/tmp/debug</span>
    +										folder after each stage of the detection process.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +					<hr class="separatorSmall">
    +					</details>
    +
    +					<h3>Export module</h3>
    +					<details sub><summary></summary>
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th class="nowrap">Available in Flows</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li>Daytime Capture</li>
    +										<li>Nighttime Capture</li>
    +									</ul>
    +								</td>
    +								<td>
    +									<p>Exports all Allsky variables to a json file.
    +										By default all environment variables prefixed with
    +										<code>AS_</code> are exported but via the
    +										Module Options other Allsky variables
    +										can also be made available.
    +										This can be used by external programs.</p>
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Export Settings"
    +								src="module-export-settings-home.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>File Location</strong> -
    +										The location to save the file.
    +										Certain Allsky variables can be used to construct the path
    +										- see the variables section below for more details.</li>
    +									<li><strong>Extra data to export</strong> -
    +										A comma separated list of
    +										additional variables to save.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +					<hr class="separatorSmall">
    +					</details>
    +
    +					<h3>Overlay module</h3>
    +					<details sub><summary></summary>
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th class="nowrap">Available in Flows</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li>Daytime Capture</li>
    +										<li>Nighttime Capture</li>
    +									</ul>
    +								</td>
    +								<td>
    +									<p>Overlays data on the captured image.
    +										This module applies the fields defined in the
    +										<a allsky="true" external="true"
    +											href="/documentation/overlays/overlays.html">
    +											<span class="editorName">Overlay Editor</span></a>.</p>
    +									<blockquote>Typically the overlay module will run
    +										towards if not at the end of the flow.
    +										This will allow it access to other variables
    +										created by modules.</blockquote>
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +					<hr class="separatorSmall">
    +					</details>
    +
    +					<h3>Script module</h3>
    +					<details sub><summary></summary>
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th class="nowrap">Available in Flows</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li>Daytime Capture</li>
    +										<li>Nighttime Capture</li>
    +									</ul>
    +								</td>
    +								<td>
    +									<p>This allows a script to be run.
    +										This should only be used by users
    +										that understand how scripts are developed/run on Linux.
    +										<strong>Extreme</strong> care must be taken when
    +										using this module as it could cause
    +										Allsky to stop operating.
    +									</p>
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Script Settings"
    +								src="module-script-settings-home.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>File Location</strong> -
    +										The full path name to the script to execute.
    +										The module will check to ensure the script exists and
    +										is executable before any attempt is made to run it.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +					<hr class="separatorSmall">
    +					</details>
    +
    +					<h3>Save Image Data module</h3>
    +					<details sub><summary></summary>
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th class="nowrap">Available in Flows</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li>Daytime Capture</li>
    +										<li>Nighttime Capture</li>
    +									</ul>
    +								</td>
    +								<td>
    +									<p>This module writes image related data to a
    +										database allowing it to be graphed in the WebUI.</p>
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +					</details>
    +
    +				</details>
    +
    +
    +				<h2>User Modules</h2>
    +				<details>
    +					<summary></summary>
    +					<p>You must manually install User Modules from a
    +						<a external="true" href="https://github.com/AllskyTeam/allsky-modules">
    +							separate GitHub repository</a>.</p>
    +
    +					<h3>Cloud Cover module</h3>
    +					<details sub><summary></summary>
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th class="nowrap">Available in Flows</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li>Nighttime Capture</li>
    +									</ul>
    +								</td>
    +								<td>
    +									<blockquote>This module requires
    +											external hardware for its operation.</blockquote>
    +									<p>This module uses an
    +										<a external="true"
    +											href="https://www.sparkfun.com/datasheets/Sensors/Temperature/MLX90614_rev001.pdf">MLX90614</a>
    +										to determine the amount of cloud cover.
    +										The MLX90614 is a non-contact Infra Red thermometer.
    +										It is used to measure the temperature of the sky and
    +										compare it to the ambient temperature.
    +										Generally the difference between the Sky temperature
    +										and ambient can be used to determine the amount of clouds.
    +										The exact theory for the calculations is beyond
    +										the scope of this documentation.</p>
    +									<p>Two different methods are available for
    +										determining the cloud cover:</p>
    +									<ol>
    +										<li><strong>Simple</strong> -
    +											The difference between Sky and ambient
    +											temperature is used to determine the cloud cover.</li>
    +										<li><strong>Advanced</strong> -
    +											Uses a polynomial model to correct
    +											the sky temperature reading.
    +											The forumale used in this method are derived from
    +											<a href="https://indiduino.wordpress.com/2013/02/02/meteostation/"
    +												external="true">
    +												https://indiduino.wordpress.com/2013/02/02/meteostation/</a>
    +											and
    +											<a href="https://lunaticoastro.com/aagcw/TechInfo/SkyTemperatureModel.pdf"
    +												external="true">
    +												https://lunaticoastro.com/aagcw/TechInfo/SkyTemperatureModel.pdf</a>
    +										</li>
    +									</ol>
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Cloud Cover Settings"
    +								src="module-cloudcover-settings-sensor.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>i2C Address</strong> -
    +										The default i2C address for the MLX90614 is 0x5A.
    +										If you need a different address specify it here in Hex.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Cloud Cover Settings"
    +								src="module-cloudcover-settings-settings.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>Clear Below &deg;C</strong> -
    +										Below this sky temperature it is assumed to be clear.</li>
    +									<li><strong>Cloudy Above &deg;C</strong> -
    +										Above this sky temperature it is assumed to be cloudy.</li>
    +								</ul>
    +								<p>Between the two above temperatures the sky is
    +									assumed to be partially cloudy.</p>
    +							</td>
    +						</tr>
    +					</table>
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Cloud Cover Settings"
    +								src="module-cloudcover-settings-advanced.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>Use advanced mode</strong> -
    +										If enabled the polynomial model will be used.</li>
    +								</ul>
    +								<ul class="minimalPadding">
    +									<li><strong>k1</strong> - TBC</li>
    +									<li><strong>k2</strong> - TBC</li>
    +									<li><strong>k3</strong> - TBC</li>
    +									<li><strong>k4</strong> - TBC</li>
    +									<li><strong>k5</strong> - TBC</li>
    +									<li><strong>k6</strong> - TBC</li>
    +									<li><strong>k7</strong> - TBC</li>
    +								</ul>
    +								<p>Details of the formulae used can be found on the
    +									<a external="true"
    +										href="https://lunaticoastro.com/aagcw/TechInfo/SkyTemperatureModel.pdf">
    +										lunaticoastro website</a>.</p>
    +								<blockquote>Setting these values requires a lot of experimentation.
    +								</blockquote>
    +							</td>
    +						</tr>
    +					</table>
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Cloud Cover Circuit"
    +								src="cloud-cover.png" width="500px" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<p>A typical connection diagram for a Cloud Cover sensor.
    +									As mentioned above the
    +									process of detecting clouds is not simple.
    +									The basic principle is to measure the difference
    +									between the ambient temperature and the sky temperature.
    +									The ambient is fairly easy to measure but the sky temperature
    +									presents some problems:</p>
    +								<ol>
    +									<li>Field of View (FOV) -
    +										A narrow FOV will lead to results just above the
    +										sensor whereas a wide FOV will have less sensitivity.
    +										The narrow FOV versions provide a more
    +										accurate reading albeit with a much narrower FOV.</li>
    +									<li>Moisture -
    +										Since the sensor needs to be pointed vertically,
    +										if any moisture from rain/snow sits on it then the
    +										reading will be inaccurate.</li>
    +								</ol>
    +								<p>The moisture problem is a little more difficult to
    +									solve and the best solution is to mount
    +									the sensor horizontally and use a reflective
    +									surface to reflect the sky onto the sensor.
    +									This allows the sensor to stay dry.</p>
    +								<p>Several environment variables are created that can be used
    +									in the <span class="managerName">Overlay Manager</span>:</p>
    +								<ul class="minimalPadding">
    +									<li><strong>CLOUDAMBIENT</strong> -
    +										The ambient temperature.</li>
    +									<li><strong>CLOUDSKY</strong> -
    +										The sky temperature.</li>
    +									<li><strong>CLOUDCOVER</strong> -
    +										A string, either "Clear", "Partial" or "Coudy".</li>
    +									<li><strong>CLOUDCOVERPERCENT</strong> -
    +										The percentage of cloud cover, only
    +										available when using the Advanced method.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +					<hr class="separatorSmall">
    +					</details>
    +
    +					<h3>Dew Heater module</h3>
    +					<details sub><summary></summary>
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th class="nowrap">Available in Flows</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li>Daytime Capture</li>
    +										<li>Nighttime Capture</li>
    +										<li>Periodic jobs</li>
    +									</ul>
    +								</td>
    +								<td>
    +									<blockquote>
    +										This module requires external
    +										hardware for its operation.</blockquote>
    +									<p>This module allows you to control a digital GPIO
    +										pin that can in turn be used to drive a dew heater.
    +										The GPIO pin cannot directly drive the heater,
    +										instead it must be used to drive some form of switch or
    +										relay / transistor etc. to control the heater.
    +										The electronics to do
    +										this are beyond the scope of this documentation.</p>
    +									<p>Some form of sensor is required to obtain
    +										the current temperature and humidity.
    +										The module supports the most common form of sensors
    +										available on the market as of 2024.</p>
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Dew Heater Settings"
    +								src="module-dew-settings-sensor.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>Sensor Type</strong> -
    +										The type of sensor to use:
    +										<ul>
    +											<li><strong>SHT31</strong> -
    +												Uses an SHT31 on i2c address 0x44.
    +												The alert mode on the SHT31 is not supported.</li>
    +											<li><strong>DHT22</strong> -
    +												Uses a DHT22 sensor connected to a GPIO pin.
    +												The pin is selected in the "Input Pin" option.</li>
    +											<li><strong>DHT11</strong> -
    +												Uses a DHT11 sensor connected to a GPIO pin.
    +												The pin is selected in the "Input Pin" option.</li>
    +											<li><strong>BME280-I2C</strong> -
    +												Uses a BME280 in i2c mode on address 0x77.</li>
    +										</ul>
    +									</li>
    +									<li><strong>Input Pin</strong> -
    +										The GPIO pin for the DHT11/22 sensors.</li>
    +									<li><strong>i2C Address</strong> -
    +										If your sensor uses i2c and has a different address
    +										to the standard one for the sensor,
    +										enter the HEX address here.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Dew Heater Settings"
    +								src="module-dew-settings-heater.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>Heater Pin</strong> -
    +										The GPIO pin for the heater control.
    +									</li>
    +									<li><strong>Startup Mode</strong> -
    +										Determines if the heater should be
    +										on when allsky starts or off.</li>
    +									<li><strong>Invert Relay</strong> -
    +										Normally the GPIO pin will go high to enable a relay.
    +										Selecting this option if the relay is wired to
    +										activate on the GPIO pin going Low.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Dew Heater Settings"
    +								src="module-dew-settings-control.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>Delay</strong> -
    +										The delay in seconds between sensor readings.
    +										If running in Daytime or Nightime Capture
    +										flows then the frame exposure
    +										time needs to be taken into account.</li>
    +									<li><strong>Limit</strong> -
    +										If the temperature is within this many degrees of
    +										the dew point the heater will be enabled or disabled.</li>
    +									<li><strong>Forced Temperature</strong> -
    +										If the temperature is below
    +										this value the heater will always be enabled.</li>
    +									<li><strong>Max Heater Time</strong> -
    +										Not yet implemented.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td>
    +								<img allsky="true" loading="lazy" alt="Dew Heater Circuit"
    +									src="dew-heater.png" width="800px" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<p>A typical connection diagram for a dew heater.
    +									In this example an SHT31 sensor is
    +									being used to provide the temperature and humidity data.
    +									The module will then determine if the heater is
    +									required and enable the relevant GPIO pin -
    +									20 in this case.</p>
    +								<blockquote class="warning">
    +									Driving relays directly from the Pi's GPIO
    +									pins can be problematic and cause damage to your Pi.
    +									If you are using a module then please ensure it has a
    +									<a external="true"
    +										href="https://en.wikipedia.org/wiki/Snubber">snubber</a>
    +									and is optically isolated.
    +									Please check the <a external="true"
    +										href="https://forums.raspberrypi.com/viewtopic.php?f=91&t=83372&p=1225448#p1225448">
    +										Raspberry Pi FAQ</a> for more details.</blockquote>
    +
    +								<p>Several environment variables are created that can be used
    +									in the <span class="managerName">Overlay Manager</span>:</p>
    +								<ul class="minimalPadding">
    +									<li><strong>DEWCONTROLAMBIENT</strong> -
    +										The ambient temperature.</li>
    +									<li><strong>DEWCONTROLDEW</strong> -
    +										The calculated dew point.</li>
    +									<li><strong>DEWCONTROLHUMIDITY</strong> -
    +										The Humidity.</li>
    +									<li><strong>DEWCONTROLHEATER</strong> -
    +										Either "on" or "off" indicating the
    +										status of the dew heater.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +					<hr class="separatorSmall">
    +					</details>
    +
    +					<h3>GPIO module</h3>
    +					<details sub><summary></summary>
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th class="nowrap">Available in Flows</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li>Night/Day Transition</li>
    +										<li>Day/Night Transition</li>
    +									</ul>
    +								</td>
    +								<td>
    +									<blockquote>
    +										This module requires external
    +										hardware for its operation.</blockquote>
    +									<p>This module allows a GPIO pin's state to
    +										be set on the transitions between day/night and night/day.
    +										This could for example be used to
    +										trigger some external electronics to cover and
    +										uncover the camera to protect it from direct
    +										sunlight in very warm climates.</p>
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="GPIO Settings"
    +									src="module-gpio-settings-home.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>GPIO Pin</strong> -
    +										The GPIO pin to set.</li>
    +									<li><strong>Pin State</strong> -
    +										The state to set the pin to - 0 for off, 1 for on.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +					<hr class="separatorSmall">
    +					</details>
    +
    +					<h3>Discord module</h3>
    +					<details sub><summary></summary>
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th class="nowrap">Available in Flows</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li>Nighttime Capture</li>
    +										<li>Daytime Capture</li>
    +										<li>Night to Day Transition</li>
    +									</ul>
    +								</td>
    +								<td>
    +									<blockquote>
    +										This module requires
    +										configuration in Discord.
    +										<br>It also requires Python 3.9.0 or greater.
    +										The module installer will not allow the module to be
    +										installed if you do not have the correct version
    +										of Python installed.</blockquote>
    +									<p>This module allows images to be sent to Discord channels.
    +										The following images can be sent to Discord:</p>
    +									<ul class="minimalPadding">
    +										<li>Daytime images -
    +											To post these include the module in the
    +											<strong>Daytime Capture</strong> flow.</li>
    +										<li>Nightime images -
    +											To post these include the module in the
    +											<strong>Nighttime Capture</strong> flow.</li>
    +										<li>Startrails, Keograms, Daily Timelapse videos -
    +											To post these files include the module in the
    +											<strong>Night to Day Transition</strong> flow.</li>
    +									</ul>
    +
    +									<blockquote>
    +										Discord implements a <a external="true"
    +											href="https://discord.com/developers/docs/topics/rate-limits">
    +											rate limit</a>
    +										on the API but it is very unlikely that
    +										you will hit any of the limits.
    +										<br>
    +										Discord also implements a limit of 8 MB for any posted item.
    +										The timelapse videos may exceed this
    +										rate so if you wish to send them to Discord you will have to
    +										configure Allsky to limit video to less than 8 MB.
    +										The module checks before sending a file and
    +										if it exceeds the Discord limit the file is not sent
    +										and an error is logged in the allsky log file.</blockquote>
    +									<p>You will need to create Webhooks in your Discord server.
    +										These can be created from the server settings
    +										as shown below:</p>
    +									<img allsky="true" loading="lazy" src="discord-webhooks.png"
    +										alt="Discord Webhook" />
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Discord Settings"
    +								src="module-discord-settings-day.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>Post Daytime Images</strong> -
    +										Select this option to post
    +										daytime images to the Discord Server.</li>
    +									<li><strong>Daytime Count</strong> -
    +										Every x images send the current
    +										image to the Discord server.</li>
    +									<li><strong>Webhook URL</strong> -
    +										The webhook for the daytime images.
    +										The value for this field is created in the Discord
    +										server's settings under "integration".</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Discord Settings"
    +								src="module-discord-settings-night.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								These settings are the same as for Day Time.</td>
    +						</tr>
    +					</table>
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Discord Settings"
    +								src="module-discord-settings-startrails.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>Post Startrails Images</strong> -
    +										Select this option to post
    +										startrails images to the Discord Server.</li>
    +									<li><strong>Webhook URL</strong> -
    +										The webhook for the startrails images.
    +										The value for this field is created in the Discord
    +										server's settings under integration.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Discord Settings"
    +								src="module-discord-settings-keograms.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>Post Keograms Images</strong> -
    +										Select this option to post
    +										keogram images to the Discord Server.</li>
    +									<li><strong>Webhook URL</strong> 
    +										The webhook for the keogram images.
    +										The value for this field is created in the Discord
    +										server's settings under integration.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Discord Settings"
    +								src="module-discord-settings-timelapse.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>Post Timelapse videos</strong> -
    +										Select this option to post
    +										timelapse videos to the Discord Server.</li>
    +									<li><strong>Webhook URL</strong> -
    +										The webhook for the timelapse videos.
    +										The value for this field is created in the Discord
    +										server's settings under "integration".</li>
    +								</ul>
    +								<blockquote>
    +									Discord implements a limit of 8 MB for any posted item.
    +									It's possible that the timelapse videos may exceed this rate
    +									so if you wish to send them to Discord you will have
    +									to configure Allsky to ensure that the video is less than 8 MB.
    +									The module will check before sending the file
    +									and if it exceeds the Discord limit it will not be sent
    +									and an error logged in the allsky log file.</blockquote>
    +							</td>
    +						</tr>
    +					</table>
    +					<hr class="separatorSmall">
    +					</details>
    +
    +					<h3>Rain module</h3>
    +					<details sub><summary></summary>
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th class="nowrap">Available in Flows</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li>Nighttime Capture</li>
    +										<li>Daytime Capture</li>
    +									</ul>
    +								</td>
    +								<td>
    +									<blockquote>This module requires external hardware.</blockquote>
    +									<p>This module uses an external sensor to detect rain.
    +										There are various cheap sensors available to detect
    +										rain that either provide an analog or digital output.
    +										This module only supports sensors with a
    +										<strong>digital</strong> output.</p>
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Rain Settings"
    +								src="module-rain-settings-home.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>Input Pin</strong> -
    +										The GPIO pin the rain sensor is connected to.</li>
    +									<li><strong>Invert Sensor</strong> -
    +										Normally the sensor will be high
    +										for clear and low for rain.
    +										This setting will reverse this.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Cloud Cover Circuit"
    +								src="rain-detection.png" width="500px" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<p>A typical connection diagram for rain detection using
    +									a cheap rain detection module.</p>
    +								<p>These cheap sensors work but
    +									the contact boards suffer badly from corrosion.
    +									A much better module is described
    +									<a href="https://www.kemo-electronic.de/en/House/Garden/M152-Rain-Sensor-12-V-DC.php"
    +										external="true">here</a>.
    +									Whilst more expensive and requiring a 12 volt supply,
    +									this is a far superior rain and snow detector.</p>
    +								<p>Two environment variables are created that can be used
    +									in other modules or the
    +									<span class="managerName">Overlay Manager</span>:</p>
    +								<ol class="minimalPadding">
    +									<li><strong>RAINSTATE</strong> -
    +										A text string either "Not Raining" or "Raining".</li>
    +									<li><strong>ALLSKYRAINFLAG</strong> -
    +										 A text string either "False" or "True".</li>
    +								</ol>
    +							</td>
    +						</tr>
    +					</table>
    +					<hr class="separatorSmall">
    +					</details>
    +
    +					<h3>Open Weather Map module</h3>
    +					<details sub><summary></summary>
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th class="nowrap">Available in Flows</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li>Periodic jobs</li>
    +									</ul>
    +								</td>
    +								<td>
    +									<p>This module reads weather data from the
    +										free Open Weather Map API.
    +										To use this module you need to signup for
    +										a free API key on the
    +										<a href="https://openweathermap.org/" external="true">
    +											openweathermap</a>
    +										website.
    +										The location used for the weather is your
    +										<span class="WebUISetting">Latitude</span>
    +										and
    +										<span class="WebUISetting">Longitude</span>.</p>
    +									<blockquote>The free tier of the API is limited to 1000
    +										calls per day so you will need to configure
    +										the settings appropriately.
    +										Reading the data every 10 minutes is more than
    +										frequent enough and will
    +										ensure you do not exceed the API limit.</blockquote>
    +									<p>Several environment variables are created
    +										that can be used in the
    +										<span class="managerName">Overlay Manager</span>:</p>
    +									<ul class="minimalPadding">
    +										<li><strong>OWWEATHER</strong> -
    +											A text string representing the weather.</li>
    +										<li><strong>OWWEATHERDESCRIPTION</strong> -
    +											A text string representing the weather.</li>
    +										<li><strong>OWTEMP</strong> -
    +											The temperature.</li>
    +										<li><strong>OWTEMPFEELSLIKE</strong> -
    +											What the temperature feels like.</li>
    +										<li><strong>OWTEMPMIN</strong> -
    +											The minimum temperature.</li>
    +										<li><strong>OWTEMPMAX</strong> -
    +											The maximum temperature.</li>
    +										<li><strong>OWPRESSURE</strong> -
    +											The pressure.</li>
    +										<li><strong>OWHUMIDITY</strong> -
    +											The humidity.</li>
    +										<li><strong>OWWINDSPEED</strong> -
    +											The wind speed.</li>
    +										<li><strong>OWWINDDIRECTION</strong> -
    +											The wind direction.</li>
    +										<li><strong>OWWINDGUST</strong> -
    +											The wind gust speed.</li>
    +										<li><strong>OWCLOUDS</strong> -
    +											The cloud cover.</li>
    +										<li><strong>OWRAIN1HR</strong> -
    +											Rainfall within the last hour.</li>
    +										<li><strong>OWRAIN3HR</strong> -
    +											Rainfall within the last three hours.</li>
    +										<li><strong>OWSUNRISE</strong> -
    +											Time the Sun rises.</li>
    +										<li><strong>OWSUNSET</strong> -
    +											Time the Sun sets.</li>
    +									</ul>
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Open Weather Map Settings"
    +								src="module-openweathermap-settings-home.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>API Key</strong> -
    +										The API key you generated after signing
    +										up to Open Weather Map.</li>
    +									<li><strong>Filename</strong> -
    +										The name of the "extra data" file the
    +										Open Weather Map data is saved in.
    +										Normally you should not need to change this.</li>
    +									<li><strong>Read Every</strong> -
    +										Call the API every x seconds.
    +										Be mindful of the 1000 API limit per
    +										day when setting this value.</li>
    +									<li><strong>Units</strong> -
    +										The units to express the data in, either
    +										"Standard" (SI Units), "Metric" or "Imperial".</li>
    +									<li><strong>Expiry Time</strong> -
    +										The number of seconds the data is valid for.
    +										See the
    +										<a allsky="true" external="true"
    +											href="/documentation/overlays/overlays.html">
    +											<span class="editorName">Overlay Editor</span></a>
    +										documentation for details.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +					<hr class="separatorSmall">
    +					</details>
    +
    +					<h3>GPS module</h3>
    +					<details sub><summary></summary>
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th class="nowrap">Available in Flows</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li>Periodic jobs</li>
    +									</ul>
    +								</td>
    +								<td>
    +									<p>This module was developed for those that
    +										use their allsky cameras in mobile locations.
    +										It was developed to allow the Pi's location and
    +										clock to be set from the GPS data.</p>
    +									<p>This module requires a GPS connected to the Pi
    +										that can be managed by gpsd.</p>
    +									<blockquote><strong>NOTE:</strong>
    +										The HDMI and Wifi on the Pi 4 is VERY noisy and
    +										will interfere with most GPS modules.
    +										To get around this please ensure
    +										that the GPS receiver and antenna is mounted
    +										at least one meter (three feet)
    +										away from the Pi.</blockquote>
    +
    +									<h4>Time synchronisation</h4>
    +									<p>Even if you set the time sync options in the
    +										GPS module the time will only be synchronised if the
    +										Pi is NOT having its time updated from the Internet.
    +										To test if the time is currently being synchronised
    +										fom the Internet enter the following command:
    +										<br><code>timedatectl status</code></p>
    +
    +									<p>Output similar to the following will be produced:</p>
    +									<div class="modulecode">
    +										<br><tt>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
    +												Local time: Fri 2023-02-03 23:18:36 GMT</tt>
    +										<br><tt>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
    +												Universal time: Fri 2023-02-03 23:18:36 UTC</tt>
    +										<br><tt>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
    +												RTC time: n/a</tt>
    +										<br><tt>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
    +												Time zone: Europe/London (GMT, +0000)</tt>
    +										<br><tt>System clock synchronized: <span class="red">no</span></tt>
    +										<br><tt>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
    +												NTP service: <span class="red">inactive</span></tt>
    +										<br><tt>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
    +												RTC in local TZ: <span class="red">no</span></tt>
    +									</div>
    +
    +									<p>Note that the "System clock synchronized" value is "no"
    +										which means the GPS module will
    +										be allowed to set the time.</p>
    +									<p>Several environment variables are created that
    +										can be used in the
    +										<span class="managerName">Overlay Manager</span>:</p>
    +									</p>
    +									<ul class="minimalPadding">
    +										<li><strong>PIGPSFIX</strong> -
    +											A text string, either "Yes" or "No",
    +											indicating if the GPS has a fix or not.</li>
    +										<li><strong>PIGPSUTC</strong> -
    +											The UTC time from the GPS.</li>
    +										<li><strong>PIGPSLOCAL</strong> -
    +											The local time from the GPS.</li>
    +										<li><strong>PIGPSOFFSET</strong> -
    +											The time offset from UTC in hours.</li>
    +										<li><strong>PIGPSLAT</strong> -
    +											The GPS latitude in degrees, minutes, and seconds.</li>
    +										<li><strong>PIGPSLON</strong> -
    +											The GPS longitude in degrees, minutes, and seconds.</li>
    +										<li><strong>PIGPSLATDEC</strong> -
    +											The GPS latitude in decimal degrees, minutes,
    +											and seconds.</li>
    +										<li><strong>PIGPSLONDEC</strong> -
    +											The GPS longitude in decimal degrees, minutes,
    +											and seconds.</li>
    +										<li><strong>PIGPSFIXDISC</strong> 
    +											The latitude and longitude discrepancy string,
    +											if there is a decrepancy found.</li>
    +									</ul>
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="GPS Settings"
    +								src="module-gps-home.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>LAT/LON Warning</strong> -
    +										If enabled a warning will be generated, both
    +										in the log files and as an Allsky variable
    +										if the GPS position does not match
    +										your
    +										<span class="WebUISetting">Latitude</span>
    +										and
    +										<span class="WebUISetting">Longitude</span>.
    +										The comparison is done to 2 decimal places to allow for
    +										GPS fluctuation.</li>
    +									<li><strong>Set LAT/LON</strong> -
    +										If enabled your
    +										<span class="WebUISetting">Latitude</span>
    +										and
    +										<span class="WebUISetting">Longitude</span>
    +										will be set from the GPS.</li>
    +									<li><strong>Set Time</strong> -
    +										If enabled the time on the Pi will be set from the GPS.</li>
    +									<li><strong>Set Every</strong> -
    +										If the "Set Time" option is enabled the time on the
    +										Pi will be set every this number of seconds.</li>
    +									<li><strong>Extra Data Filename</strong> -
    +										The name of the file to create the GPS data in for the
    +										<span class="managerName">Overlay Manager</span>.
    +										Normally you will not need to change this.</li>
    +									<li><strong>Discrepancy Warning</strong> -
    +										if the "Lat/Lon Warning" is enabled and a
    +										discrepancy is found this text will be set
    +										in the variable for the
    +										<span class="managerName">Overlay Manager</span>.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="GPS Obfuscation Settings"
    +								src="module-gps-obfuscate.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>Obfuscate Position</strong> -
    +										If enabled the values below will be used
    +										to modify the GPS position.
    +										This is designed to allow you to display the GPS
    +										position on an overlay without giving away
    +										your exact position.</li>
    +									<li><strong>Latitude Metres</strong> -
    +										The number of metres to offset the latitude by.
    +										Can be a positive or negative number.</li>
    +									<li><strong>Longitude Metres</strong> -
    +										The number of metres to offset the longitude by.
    +										Can be a positive or negative number.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="GPS connection"
    +								src="gps.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								A typical setup with a GPS connected to a Pi.
    +							</td>
    +						</tr>
    +					</table>
    +					<hr class="separatorSmall">
    +					</details>
    +
    +
    +					<h3>PI Status module</h3>
    +					<details sub><summary></summary>
    +					<table class="module-info vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th class="nowrap">Available in Flows</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tbody>
    +							<tr>
    +								<td>
    +									<ul>
    +										<li>Periodic jobs</li>
    +									</ul>
    +								</td>
    +								<td>
    +									<p>This module reads information about the Pi
    +										and makes it availbale for use in the
    +										<span class="managerName">Overlay Manager</span>.</p>
    +									<p>Several environment variables are created that
    +										can be used in the
    +										<span class="managerName">Overlay Manager</span>:</p>
    +									</p>
    +									<ul>
    +										<li><strong>DISKSIZE</strong> -
    +											The size of the main disk in the Pi.</li>
    +										<li><strong>DISKUSAGE</strong> -
    +											Amount of space used on the disk.</li>
    +										<li><strong>DISKFREE</strong> -
    +											Amount of free space on the disk.</li>
    +										<li><strong>CPUTEMP</strong> -
    +											CPU temp in C/F Only available if the Allsky temp
    +											settings is Celsius or Fahrenheit.</li>
    +										<li><strong>CPUTEMP_C</strong> -
    +											CPU temp in C Only available if the Allsky temp
    +											settings is set to Celsius.</li>
    +										<li><strong>CPUTEMP_F</strong> -
    +											CPU temp in F Only available if the Allsky temp
    +											settings is set to Fahrenheit.</li>
    +										<li><strong>THROTTLEDBINARY</strong> -
    +											Output of vcgencmd get_throttled.</li>
    +										<li><strong>TSTAT{X}</strong> -
    +											Throttled bits, see table below.</li>
    +										<li><strong>TSTATSUMARYTEXT</strong> -
    +											Textual summary of all of the tstats bits.</li>
    +									</ul>
    +									<blockquote>Several other variables are also available
    +										and are mainly related to
    +										clock frequencies and voltages.</blockquote>
    +									<h4>Tstat bits</h4>
    +									<p>These values are text and either "true" or "false":</p>
    +									<table style="width: 50%">
    +										<thead>
    +										<tr class="tableHeader">
    +											<th>Variable</th>
    +											<th>Meaning</th>
    +										</tr>
    +										</thead>
    +										<tr>
    +											<td>TSTAT0</td>
    +											<td>Under-voltage detected.</td>
    +										</tr>
    +										<tr>
    +											<td>TSTAT1</td>
    +											<td>Arm frequency capped.</td>
    +										</tr>
    +										<tr>
    +											<td>TSTAT2</td>
    +											<td>Currently throttled.</td>
    +										</tr>
    +										<tr>
    +											<td>TSTAT2</td>
    +											<td>Currently throttled.</td>
    +										</tr>
    +										<tr>
    +											<td>TSTAT3</td>
    +											<td>Soft temperature limit active.</td>
    +										</tr>
    +										<tr>
    +											<td>TSTAT16</td>
    +											<td>Under-voltage has occurred.</td>
    +										</tr>
    +										<tr>
    +											<td>TSTAT17</td>
    +											<td>Arm frequency capping has occurred.</td>
    +										</tr>
    +										<tr>
    +											<td>TSTAT18</td>
    +											<td>Throttling has occurred.</td>
    +										</tr>
    +										<tr>
    +											<td>TSTAT19</td>
    +											<td>Soft temperature limit has occurred.</td>
    +										</tr>
    +									</table>
    +
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +					<br>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="PI Status Settings"
    +								src="module-pi-status.png"/></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<ul>
    +									<li><strong>Read Every</strong> -
    +										Reads the pi status every this number of seconds.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +					</table>
    +					</details>
    +
    +				</details>
    +			</div>
    +
    +
    +			<h1 id="masks">Creating and Using Masks</h1>
    +			<div>
    +				<p>Several modules can make use of masks, which are used to "hide"
    +					areas of the image to improve the final image.
    +					For example, the <span class="moduleName">Star Count</span> module can use a
    +					mask to hide all areas of the image that may contain things that
    +					will confuse the star detection algorithm, such as local light pollution
    +					like a far away street light.</p>
    +				<blockquote>Masks must be created in image editing software such as
    +					Gimp or Photoshop.
    +					There are no tools available within Allsky to create masks.</blockquote>
    +				<details>
    +					<summary></summary>
    +					<p>Masks can also be used to clean up or hide parts of the image
    +						like buildings, trees, or anything not created by the lens.</p>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td>
    +								<p>Consider the following raw image from the camera.
    +									The areas outside of the image created by the lens
    +									contain a lot of noise.</p>
    +							</td>
    +							<td width="33%"><a href="mask-raw.png" title="Click to enlarge">
    +								<img allsky="true" loading="lazy" alt="Raw Mask Image"
    +									src="mask-raw.png" /></a></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<p>To clean this up we create a mask which is an
    +									image that only contains pure white and pure black -
    +									no gray.
    +									Any areas in white will be kept and any areas in
    +									black will be masked.</p>
    +							</td>
    +							<td><img allsky="true" loading="lazy" alt="Mask"
    +								src="mask-raw-mask.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<p>After the mask has been applied the resulting
    +									image looks a lot cleaner.</p>
    +							</td>
    +							<td><a href="mask-raw-final.png" title="Click to enlarge">
    +								<img allsky="true" loading="lazy" alt="Masked Image"
    +									src="mask-raw-final.png" /></a></td>
    +						</tr>
    +					</table>
    +
    +					<br>
    +					<p>Masks are also used to hide areas of the image for some modules
    +						like <span class="moduleName">Star Count</span>.
    +						This prevents false positives.
    +						Consider the following example of another raw image from the camera.</p>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td>
    +								<p>There is a lot of non-sky in the image like houses, trees, etc.
    +									These items can cause some detection modules
    +									to get confused so by masking them they will not
    +									be included in any of the calculations.</p>
    +							</td>
    +							<td width="33%"><a href="mask-stars-raw.png" title="Click to enlarge">
    +								<img allsky="true" loading="lazy" alt="Raw Mask Image"
    +									src="mask-stars-raw.png" /></a></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<p>This mask hides areas of the image that are not sky.</p>
    +								<blockquote>Since this mask is being used to
    +									mask specific areas of the image, if the camera is
    +									moved then the mask may have to be recreated.</blockquote>
    +							</td>
    +							<td><a href="mask-stars-mask.png" title="Click to enlarge">
    +								<img allsky="true" loading="lazy" alt="Mask"
    +									src="mask-stars-mask.png" /></a></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<p>After the mask has been applied the resulting image
    +									will allow the various modules to process the image
    +									more effectively.</p>
    +							</td>
    +							<td><a href="mask-stars-result.png" title="Click to enlarge">
    +								<img allsky="true" loading="lazy" alt="Masked Image"
    +									src="mask-stars-result.png" /></a></td>
    +						</tr>
    +					</table>
    +
    +
    +					<blockquote>
    +						Some modules will apply the mask but it will not be used
    +						for the final image.
    +						Examples of this are masks used to mask the image for
    +						star/meteor detection.</blockquote>
    +					<blockquote>
    +						Masks do not have to be circular, they can be any shape you like.
    +						In fact the masks used for star and meteor detection are
    +						very likely to be a strange shape.</blockquote>
    +				</details>
    +			</div>
    +
    +
    +			<h1>GPIO</h1>
    +			<div>
    +				<p>Several of the modules require the selection of a GPIO pin on the pi.
    +					The module editor has an inbuilt GPIO selector to help ease
    +					the process of selecting the required pin.</p>
    +				<details>
    +					<summary></summary>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td>
    +								<p>When clicking on the GPIO selection the GPIO selector
    +									is displayed allowing you to select the required pin.
    +									In this example GPIO27 is currently selected.</p>
    +								<blockquote>Currently only the PI 3/4/5 GPIO
    +									pins are implemented.</blockquote></td>
    +							<td><a href="gpio.png" title="Click to enlarge">
    +								<img allsky="true" loading="lazy" alt="GPIO Selector"
    +									src="gpio.png" /></a></td>
    +						</tr>
    +					</table>
    +				</details>
    +			</div>
    +
    +
    +			<h1>Module Performance / Debugging</h1>
    +			<div>
    +				<p>It's important that modules do not take too much time to run as this
    +					will cause load issues on the Pi.</p>
    +				<details>
    +					<summary></summary>
    +					<p>After Allsky has taken an image an initial copy is saved
    +						and processed in the background,
    +						including running the relevant module flow.
    +						It's important that this process finishes before the
    +						next image is saved.
    +						If the module flow takes too long multiple save image processes
    +						will be running, and will start to cause performance issues on the Pi.</p>
    +					<blockquote><strong>TIP</strong> - When installing a new module
    +						ensure debug mode is enabled in the
    +						<span class="managerName">Module Manager</span> Module Options
    +						 and monitor how long the new module is taking and what
    +						effect it's having on the overall flow time.</blockquote>
    +					<h2>The <span class="managerName">Module Manager</span> Debug Window</h2>
    +					<table class="module-settings vtop">
    +						<tr>
    +							<td><img allsky="true" loading="lazy" alt="Module Debug Window"
    +								src="Module-Manager-Debug.png" /></td>
    +						</tr>
    +						<tr>
    +							<td>
    +								<p>The debug dialog box shows each of the enabled modules,
    +									how long they took to run,
    +									the result of the last run, and the total execution
    +									time for all modules.</p>
    +								<p>This information is useful if you find there are too
    +									many save image processes running or for determining
    +									if any particular module is causing an issue.</p>
    +							</td>
    +						</tr>
    +					</table>
    +					<h2>The Allsky debug log</h2>
    +					<p>The main Allsky debug log will contain information from
    +						the module processor.
    +						The amount of information logged depends upon the main Allsky
    +						<span class="WebUISetting">Debug Level</span> setting -
    +						when <span class="WebUIValue">0</span>
    +						only critical module errors are logged.
    +						Any other log level will display verbose information as shown below.</p>
    +					<blockquote>Periodic module output is logged to the
    +						<span class="fileName">allskyperiodic.log</span> file.
    +						This file will be in the same location as the main Allsky log file,
    +						typically <span class="fileName">/var/log</span>.</blockquote>
    +					<br><pre>
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Loading config...
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Loading recipe...
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: -------- Running Module allsky_loadimage.py --------
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Attempting to load allsky_loadimage
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Image
    +/home/alex/allsky/tmp/image-20221023210636.jpg Loaded
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Module allsky_loadimage.py ran ok in 0.493601s
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: -------- Running Module allsky_pistatus.py --------
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Attempting to load allsky_pistatus
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Module allsky_pistatus.py ran ok in 0.041586s
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: -------- Running Module allsky_saveintermediateimage.py --------
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Attempting to load allsky_saveintermediateimage
    +Oct 23 21:08:15 allsky allsky.sh[24948]: /home/alex/allsky/images/20221023-clean
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Image
    +/home/alex/allsky/images/20221023-clean/image-20221023210636.jpg Saved
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Module
    +allsky_saveintermediateimage.py ran ok in 0.442361s
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: -------- Running Module allsky_clearsky.py --------
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Attempting to load allsky_clearsky
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Created star template. Radius - 6
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Sky is NOT clear. 5 Stars found, clear limit is 40
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: MQTT disabled
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Module allsky_clearsky.py ran ok in 0.677157s
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: -------- Running Module allsky_starcount.py --------
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Attempting to load allsky_starcount
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Sky is not clear so ignoring starcount
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Module allsky_starcount.py ran ok in 9.8e-05s
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: -------- Running Module allsky_meteor.py --------
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Attempting to load allsky_meteor
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Sky is not clear so ignoring meteor detection
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Module allsky_meteor.py ran ok in 6.8e-05sv
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: -------- Running Module allsky_dewheater.py ---
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Attempting to load allsky_dewheater
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Turning Heater on
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Temperature within limit temperature
    +11.13, limit 10, dewPoint 8.74
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Sensor SHT31 read. Temperature 11.13
    +Humidity 85.19 Dew Point 8.74 Heat Index -14.47
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Module allsky_dewheater.py ran ok in 0.040973s
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: -------- Running Module allsky_maskimage.py --------
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Attempting to load allsky_maskimage
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Module allsky_maskimage.py ran ok in 0.516113s
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: -------- Running Module allsky_cloud.py --------
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Attempting to load allsky_cloud
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Cloud state - Cloudy 100.0. Sky Temp
    +10.370000000000005, Ambient 10.450000000000045
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Module allsky_cloud.py ran ok in 0.002392s
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: -------- Running Module allsky_export.py --------
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Attempting to load allsky_export
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Allsky data exported to
    +/home/alex/allsky/tmp/allskydata.json
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Module allsky_export.py ran ok in 0.001127s
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: -------- Running Module allsky_overlay.py --------
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Attempting to load allsky_overlay
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Config file set to
    +/home/alex/allsky/config/overlay.json
    +Oct 23 21:08:15 allsky allsky.sh[24948]: INFO: Loading Config took 0.00063 Seconds.
    +Elapsed Time 0.000662 Seconds.
    +Oct 23 21:08:16 allsky allsky.sh[24948]: INFO: Adding Text Fields took 0.255803
    +Seconds. Elapsed Time 0.685933 Seconds.
    +Oct 23 21:08:16 allsky allsky.sh[24948]: INFO: Adding Image Fields took 0.248668
    +Seconds. Elapsed Time 0.934638 Seconds.
    +Oct 23 21:08:16 allsky allsky.sh[24948]: INFO: Saving Final Image took 0.006398
    +Seconds. Elapsed Time 0.941075 Seconds.
    +Oct 23 21:08:16 allsky allsky.sh[24948]: INFO: Writing debug data took 4.5e-05
    +Seconds. Elapsed Time 0.941138 Seconds.
    +Oct 23 21:08:16 allsky allsky.sh[24948]: INFO: Debug information written to
    +/home/alex/allsky/tmp/overlaydebug.txt
    +Oct 23 21:08:16 allsky allsky.sh[24948]: INFO: Annotation Complete Elapsed Time 0.942793 Seconds.
    +Oct 23 21:08:16 allsky allsky.sh[24948]: INFO: Module allsky_overlay.py ran ok in 0.951637s
    +Oct 23 21:08:16 allsky allsky.sh[24948]: INFO: -------- Running Module allsky_savedetails.py --------
    +Oct 23 21:08:16 allsky allsky.sh[24948]: INFO: Attempting to load allsky_savedetails
    +Oct 23 21:08:16 allsky allsky.sh[24948]: INFO: Module allsky_savedetails.py ran ok in 0.128938s
    +Oct 23 21:08:16 allsky allsky.sh[24948]: INFO: -------- Running Module allsky_saveimage.py --------
    +Oct 23 21:08:16 allsky allsky.sh[24948]: INFO: Attempting to load allsky_saveimage
    +Oct 23 21:08:16 allsky allsky.sh[24948]: INFO: Image
    +/home/alex/allsky/tmp/image-20221023210636.jpg Saved, quality 100
    +Oct 23 21:08:16 allsky allsky.sh[24948]: INFO: Module allsky_saveimage.py ran ok in 0.388242s
    +</pre>
    +				</details>
    +			</div>
    +
    +
    +			<h1>Developing Custom Modules</h1>
    +			<div>
    +				<p>The module system is designed to be extended by developing custom modules.</p>
    +				<details>
    +					<summary></summary>
    +					<blockquote>
    +					Personal modules should be saved in the
    +					<span class="fileName">~/allsky/config/myFiles/modules</span> directory
    +					which you must create (<code>mkdir -p ~/allsky/config/myFiles/modules</code>).
    +					<br>This directory is automatically propogated to new Allsky releases.
    +					</blockquote>
    +
    +					<h2>Python Versions</h2>
    +					<p>Modules are developed in
    +						<a external="true" href="https://www.python.org/">Python</a>
    +						and should use the latest version available for the Pi.
    +						It should be noted that whilst multiple versions of
    +						Python can be installed on a Pi it's best
    +						just to use the version that ships with the OS.
    +						Generally that may mean reinstalling your Allsky installation to
    +						get the latest version of python.
    +						Running multiple versions of python is beyond the
    +						scope of this documentation.</p>
    +
    +					<h2>Contributing a module</h2>
    +					<p>There is a central GitHub repository that you can contribute
    +						your modules to.
    +						You do not have to do this but over time we would like to
    +						build this into a comprehensive library of available modules.</p>
    +					<p>If you wish to contribute your module then please:
    +					<ul>
    +						<li>Create a fork of the <strong>User Modules</strong> repository from
    +							<a href="https://github.com/AllskyTeam/allsky-modules"
    +								external="true">
    +								https://github.com/AllskyTeam/allsky-modules</a>.</li>
    +						<li>Create a new branch in your forked repository,
    +							calling the branch the name of your module.</li>
    +						<li>Develop your module and commit it to the branch.</li>
    +						<li>Create a pull request into the allsky-modules
    +							repo from your branch.</li>
    +						<li>The module will be checked and if all is ok will
    +							be merged into the main repository.</li>
    +						<li>If you need to make changes to the module after it's
    +							been merged then refork the main repo,
    +							create a branch from the master branch named as your module,
    +							then commit a PR to main master.</li>
    +					</ul>
    +					</p>
    +					<p>The structure of a module is important so please use the
    +						following folder/file structure.</p>
    +					<p><span class="fileName">allsky_MODULENAME</span></p>
    +					<ul>
    +						<li><span class="fileName">allsky_MODULENAME.py</span> -
    +							The main modules code.</li>
    +						<li><span class="fileName">requirements.txt</span> -
    +							Any python packages required by the plugin,
    +							will be installed with <code>pip3</code>.
    +							If you don't need any additional packages don't include this file.</li>
    +						<li><span class="fileName">packages.txt</span> -
    +							Any aditional libraries required by the module,
    +							will be installed using <code>apt</code>.
    +							If you don't need any additional packages don't include this file.</li>
    +						<li><span class="fileName">README.md</span> -
    +							Markdown file with any special instructions required for the module.
    +							If there are none do not include this file.</li>
    +					</ul>
    +					<blockquote>When specifying python libraries DO NOT include <strong>numpy</strong>
    +						in ANY <span class="fileName">requirements.txt</span> file as
    +						changing the version could cause issues with Allsky.</blockquote>
    +
    +
    +					<h2>Anatomy Of A Module</h2>
    +					<p>A module consists of two key parts:</p>
    +					<ol>
    +						<li><strong>The metadata variable</strong>
    +							defines everything the
    +							<span class="managerName">Module Manager</span>
    +							needs to run the module.
    +							This includes basic information about the module
    +							and the configuration options it requires.</li>
    +						<li><strong>The module entry point</strong>
    +							is the main function that is called by the
    +							<span class="managerName">Module Manager</span>.</li>
    +					</ol>
    +					<p>Every module MUST import the <a href="#allsky_shared"><strong>allsky_shared</a></strong> module.
    +						This module is used to pass data from
    +						the flow processor to each module.</p>
    +
    +					<h3>The Meta Data Variable</h3>
    +					<blockquote>Please refer to the Boiler Plate example module for
    +						more details on this variable.
    +						That example includes all of the available options.</blockquote>
    +
    +					<table class="vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th>Field</th>
    +								<th>Type</th>
    +								<th>Mandatory</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tr>
    +							<td>name</td>
    +							<td>String</td>
    +							<td>Yes</td>
    +							<td>The name of the module.
    +								This field is displayed as the heading for a module when
    +								it's displayed in the
    +								<span class="managerName">Module Manager</span>.</td>
    +						</tr>
    +						<tr>
    +							<td>description</td>
    +							<td>String</td>
    +							<td>Yes</td>
    +							<td>The description of the module.
    +								This field is displayed in the
    +								<span class="managerName">Module Manager</span>.</td>
    +						</tr>
    +						<tr>
    +							<td>module</td>
    +							<td>String</td>
    +							<td>Yes</td>
    +							<td>The module name.
    +								<strong>MUST</strong> be in the format
    +								<span class="moduleName">allsky_{module name}</span>
    +								i.e., <span class="moduleName">allsky_boilerplate</span>.</td>
    +						</tr>
    +						<tr>
    +							<td>version</td>
    +							<td>String</td>
    +							<td>No</td>
    +							<td>The version of the module.
    +								If this field is not present then the version of the
    +								main Allsky software will be used.</td>
    +						</tr>
    +						<tr>
    +							<td>enabled</td>
    +							<td>String</td>
    +							<td>No</td>
    +							<td>"true" or "false".
    +								If set to true the module is enabled, handy for setting its
    +								initial state.
    +								<strong>NOTE:</strong> If the option to auto enable modules is
    +								enabled in the
    +								<span class="managerName">Module Manager</span>
    +								then this will take priority over that value.</td>
    +						</tr>
    +						<tr>
    +							<td>events</td>
    +							<td>Dictionary</td>
    +							<td>Yes</td>
    +							<td>A list of the flows the module should be displayed in,
    +								one or more of:
    +								<p><ul class="minimalPadding">
    +									<li><strong>Daytime Capture</strong></li>
    +									<li><strong>Nighttime Capture</strong></li>
    +									<li><strong>Night to Day Transition</strong></li>
    +									<li><strong>Day to Night Transition</strong></li>
    +									<li><strong>Periodic jobs</strong></li>
    +								</ul></p>
    +							</td>
    +						</tr>
    +						<tr>
    +							<td>experimental</td>
    +							<td>String</td>
    +							<td>No</td>
    +							<td>"true" or "false".
    +								If set to true a warning is displayed in the
    +								<span class="managerName">Module Manager</span>
    +								indicating the module is experimental.</td>
    +						</tr>
    +						<tr>
    +							<td>arguments</td>
    +							<td>Dictionary</td>
    +							<td>No</td>
    +							<td>A list of the values that will be passed to the main
    +								processing method of the module in the params array.
    +								This exists to allow you to specify defaults for the fields.
    +								Definitions for the fields are defined in the
    +								argumentdetails field below.</td>
    +						</tr>
    +						<tr>
    +							<td>argumentdetails</td>
    +							<td>Dictionary</td>
    +							<td>No</td>
    +							<td>Dictionary of definitions for each of the arguments.</td>
    +						</tr>
    +						<tr>
    +							<td>businfo</td>
    +							<td>string</td>
    +							<td>No</td>
    +							<td>Set to i2c if the module uses the i2c bus.</td>
    +						</tr>
    +						<tr>
    +							<td>changelog</td>
    +							<td>Dictionary</td>
    +							<td>Yes</td>
    +							<td>Changelog information for the module, see below.</td>
    +						</tr>
    +					</table>
    +
    +					<h4>The argumentdetails Dictionary</h4>
    +					<p>A module may require that the user can set values for its paramaters.
    +						This is implemented via the "arguments" and "argumentdetails" sections.
    +						If there are any argumentdetails present
    +						then a settings option will be displayed in the
    +						<span class="managerName">Module Manager</span>.
    +						Clicking on the setting's option displays a dialog
    +						allowing the module's settings to be changed.
    +						The dialog is created automatically from the options defined
    +						in the "argumentdetails" section.</p>
    +					<table class="vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th>Field</th>
    +								<th>Type</th>
    +								<th>Mandatory</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tr>
    +							<td>required</td>
    +							<td>String</td>
    +							<td>Yes</td>
    +							<td>"true" or "false".
    +								If set to true the field is required.
    +								<strong>NOTE:</strong>
    +								This will be implemented in a future release.</td>
    +						</tr>
    +						<tr>
    +							<td>description</td>
    +							<td>String</td>
    +							<td>Yes</td>
    +							<td>This is the label for the field in the module settings dialog.</td>
    +						</tr>
    +						<tr>
    +							<td>help</td>
    +							<td>String</td>
    +							<td>No</td>
    +							<td>This is the help text for the field in the
    +								module settings dialog.</td>
    +						</tr>
    +						<tr>
    +							<td>tab</td>
    +							<td>String</td>
    +							<td>No</td>
    +							<td>The tab to display the field in.
    +								If left blank the field will be displayed on the 'Home' tab.
    +								This is handy for grouping fields into seperate tabs.</td>
    +						</tr>
    +						<tr>
    +							<td>type</td>
    +							<td>Dictionary</td>
    +							<td>No</td>
    +							<td>A Dictionary defining the type of field.
    +								The key is the name of the field, the
    +								same name as used in the arguments section.
    +								If no type is defined the field is
    +								assumed to be a text field.</td>
    +						</tr>
    +					</table>
    +
    +					<h4>Available field types</h4>
    +					<p>If blank the field is assumed to be a text entry field.</p>
    +					<table class="vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th>fieldtype</th>
    +								<th>Description</th>
    +								<th>Additional paramaters</th>
    +							</tr>
    +						</thead>
    +						<tr>
    +							<td>select</td>
    +							<td>A drop down list from which the user can select an option.</td>
    +							<td>
    +								<ul>
    +									<li><strong>values</strong> -
    +										A comma separated list of values for the drop down.
    +										The default value for the drop down list is specified in the
    +										arguments section.</li>
    +								</ul>
    +							</td>
    +						</tr>
    +						<tr>
    +							<td>checkbox</td>
    +							<td>A checkbox allowing the entry of a true or false value.</td>
    +							<td>None</td>
    +						</tr>
    +						<tr>
    +							<td>spinner</td>
    +							<td>A numerical entry field with up and down controls
    +								to alter the value.</td>
    +							<td>
    +								<p><ul class="minimalPadding">
    +									<li><strong>min</strong> -
    +										The minimum value for the field.</li>
    +									<li><strong>max</strong> -
    +										The maximum value for the field.</li>
    +									<li><strong>step</strong> -
    +										The step for the field.</li>
    +								</ul></p>
    +							</td>
    +						</tr>
    +						<tr>
    +							<td>gpio</td>
    +							<td>Displays a dialog allowing the user to select a GPIO pin on the Pi.
    +								This field type is useful when developing a module that requries
    +								a user to select a GPIO pin.</td>
    +							<td>None</td>
    +						</tr>
    +						<tr>
    +							<td>image</td>
    +							<td>Displays a dialog allowing the user to select/upload an image.
    +								This is useful where a module may require the user
    +								to select a mask.</td>
    +							<td>None</td>
    +						</tr>
    +						<tr>
    +							<td>roi</td>
    +							<td>Displays a dialog allowing the user to select a
    +								Region Of Interest (ROI) from the captured image.</td>
    +							<td>None</td>
    +						</tr>
    +					</table>
    +
    +					<h4>The changelog Dictionary</h4>
    +					<p>A simple example with a single change, one change for the revision:</p>
    +<pre>
    +"v1.0.0" : [
    +	{
    +		"author": "Alex Greenland",
    +		"authorurl": "https://github.com/allskyteam",
    +		"changes": "Initial Release"
    +	}
    +],
    +</pre>
    +
    +					<p>A slightly more complex change, multiple changes for a single revision:</p>
    +<pre>
    +"v1.0.1" : [
    +	{
    +		"author": "Damian Grocholski (Mr-Groch)",
    +		"authorurl": "https://github.com/Mr-Groch",
    +		"changes": [
    +			"Added extra pin that is triggered with heater pin",
    +			"Fixed dhtxxdelay (was not implemented)",
    +			"Fixed max heater time (was not implemented)"
    +		]
    +	}
    +],
    +</pre>
    +
    +					<p>A change with multiple authors,
    +						not the change can be an array as in the above example:</p>
    +
    +<pre>
    +"v1.0.4" : [
    +	{
    +		"author": "Alex Greenland",
    +		"authorurl": "https://github.com/allskyteam",
    +		"changes": "Add AHTx0 i2c sensor"
    +	},
    +	{
    +		"author": "Andreas Schminder",
    +		"authorurl": "https://github.com/Adler6907",
    +		"changes": "Added Solo Cloudwatcher"
    +	}
    +],
    +</pre>
    +					<h4>Complete metadata example</h4>
    +					<p>This is a full example of all of the possible
    +						options available in the metaData dictionary.
    +						This example can be found in the boilerplate module
    +						in the additional modules repository on GitHub.</p>
    +
    +					<table class="vtop">
    +						<tr>
    +							<td>
    +<pre>
    +metaData = {
    +    "name": "All Sky Boilerplate",
    +    "description": "Example module for AllSky",
    +    "module": "allsky_boilerplate",
    +    "version": "v1.0.0",
    +    "events": [
    +        "day",
    +        "night",
    +        "endofnight",
    +        "daynight",
    +        "nightday",
    +        "periodic"
    +    ],
    +    "experimental": "false",
    +    "arguments":{
    +        "textfield": "",
    +        "select": "value1",
    +        "checkbox": "",
    +        "number": "10",
    +        "gpio": "",
    +        "image": "",
    +        "roi": ""
    +    },
    +    "argumentdetails": {
    +        "textfield": {
    +            "required": "true",
    +            "description": "Text Field",
    +            "help": "Example help for the text field",
    +            "tab": "Field Types"
    +        },
    +        "select" : {
    +            "required": "false",
    +            "description": "Select Field",
    +            "help": "Example help for a select field",
    +            "tab": "Field Types",
    +            "type": {
    +                "fieldtype": "select",
    +                "values": "None,value1,value2,value3"
    +            }
    +        },
    +        "checkbox" : {
    +            "required": "false",
    +            "description": "Checkbox Field",
    +            "help": "Example help for the checkbox field",
    +            "tab": "Field Types",
    +            "type": {
    +                "fieldtype": "checkbox"
    +            }
    +        },
    +        "number" : {
    +            "required": "true",
    +            "description": "Number Field",
    +            "help": "Example help for the number field",
    +            "tab": "Field Types",
    +            "type": {
    +                "fieldtype": "spinner",
    +                "min": 0,
    +                "max": 1000,
    +                "step": 1
    +            }
    +        },
    +        "gpio": {
    +            "required": "true",
    +            "description": "GPIO Field",
    +            "help": "Example help for the GPIO field",
    +            "tab": "Field Types",
    +            "type": {
    +                "fieldtype": "gpio"
    +            }
    +        },
    +        "image" : {
    +            "required": "false",
    +            "description": "Image Field",
    +            "help": "Example help for the image field",
    +            "tab": "Field Types",
    +            "type": {
    +                "fieldtype": "image"
    +            }
    +        },
    +        "roi": {
    +            "required": "true",
    +            "description": "Region of Interest field",
    +            "help": "Help for the region of interest field",
    +            "tab": "Field Types",
    +            "type": {
    +                "fieldtype": "roi"
    +            }
    +        },
    +        "textfield1": {
    +            "required": "true",
    +            "description": "Text Field1",
    +            "help": "Example help for the text field in a new tab",
    +            "tab": "Another Tab"
    +        }
    +    },
    +    "enabled": "false",
    +    "changelog": {
    +        "v1.0.0" : [
    +            {
    +                "author": "Alex Greenland",
    +                "authorurl": "https://github.com/allskyteam",
    +                "changes": "Initial Release"
    +            }
    +        ],
    +        "v1.0.1" : [
    +            {
    +                "author": "Alex Greenland",
    +                "authorurl": "https://github.com/Mr-Groch",
    +                "changes": [
    +                    "Change 1",
    +                    "Change 2"
    +                ]
    +            }
    +        ],
    +        "v1.0.2" : [
    +            {
    +                "author": "Alex Greenland",
    +                "authorurl": "https://github.com/allskyteam",
    +                "changes": [
    +                    "Change 1",
    +                    "Change 2"
    +                ]
    +            },
    +            {
    +                "author": "Andreas Schminder",
    +                "authorurl": "https://github.com/Adler6907",
    +                "changes": "Change 1"
    +            }
    +        ]
    +    },
    +    "businfo": [
    +        "i2c"
    +
    +    ],
    +    "changelog": {
    +        "v1.0.0" : [
    +            {
    +                "author": "Alex Greenland",
    +                "authorurl": "https://github.com/allskyteam",
    +                "changes": "Initial Release"
    +            }
    +        ],
    +        "v1.0.1" : [
    +            {
    +                "author": "Alex Greenland",
    +                "authorurl": "https://github.com/allskyteam",
    +                "changes": [
    +                    "Change 1",
    +                    "Change 2",
    +                    "Change 3"
    +                ]
    +            }
    +        ],
    +        "v1.0.2" : [
    +            {
    +                "author": "Alex Greenland",
    +                "authorurl": "https://github.com/allskyteam",
    +                "changes": "Change 1"
    +            },
    +            {
    +                "author": "John Doe",
    +                "authorurl": "https://github.com/allskyteam",
    +                "changes": [
    +                    "Change 1",
    +                    "Change 2",
    +                    "Change 3"
    +                ]
    +            }
    +        ]
    +    }
    +}
    +</pre>
    +							</td>
    +						</tr>
    +					</table>
    +
    +					<a id="allsky_shared">
    +					<h3>The Allsky Shared module</h3>
    +					<p>To improve the performance of modules a common shared module
    +						called <strong>allsky_shared</strong> is used.
    +						It contains both helper functions and data from the main capture process.
    +						The module exists so that each individual module does not
    +						have to perform tasks like loading an image.</p>
    +
    +					<h4>Allsky Shared module data</h4>
    +					<table>
    +						<thead>
    +							<tr class="tableHeader">
    +								<th>Variable</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tr>
    +							<td>args</td>
    +							<td>The arguments passed to the module processor
    +								(not normally needed).</td>
    +						</tr>
    +						<tr>
    +							<td>LOGLEVEL</td>
    +							<td>The Allsky <span class="WebUISetting">Debug Level</span>
    +								(not normally needed).</td>
    +						</tr>
    +						<tr>
    +							<td>CURRENTIMAGEPATH</td>
    +							<td>The full path to the current image (not normally needed,
    +								not available in daynight, nightday or periodic).</td>
    +						</tr>
    +						<tr>
    +							<td>TOD</td>
    +							<td>The time of day not available in daynight, nightday or periodic).</td>
    +						</tr>
    +						<tr>
    +							<td>fullFilename</td>
    +							<td>The final image filename i.e. image.jpg not available in daynight,
    +								nightday or periodic).</td>
    +						</tr>
    +						<tr>
    +							<td>image</td>
    +							<td>a numpy array of the current image -
    +								use this for any processing of the captured image.
    +								<strong>DO NOT</strong>
    +								attempt to load the image from disk within a module as
    +								this will have a severe performance impact on the module.
    +								Not available in daynight, nightday or periodic).</td>
    +						</tr>
    +					</table>
    +
    +					<h4>Allsky Shared module helpers</h4>
    +					<table class="vtop">
    +						<thead>
    +							<tr class="tableHeader">
    +								<th>Function</th>
    +								<th>Description</th>
    +							</tr>
    +						</thead>
    +						<tr>
    +							<td>log(level, message)</td>
    +							<td>Logs an entry to the allsky log file if the
    +								<span class="WebUISetting">Debug Level</span>
    +								is above "level".
    +								Errors are always logged.
    +								When a module needs to write to a log it should use this
    +								function rather than write to any log files directly.
    +								This function will ensure
    +								that the log message appears in the main Allsky log file.</td>
    +						</tr>
    +						<tr>
    +							<td>getEnvironmentVariable(name, fatal=False, error='')</td>
    +							<td>Gets an environment variable,
    +								can terminate if needed by setting the fatal
    +								variable and an error code.</td>
    +						</tr>
    +						<tr>
    +							<td>var_dump(variable)</td>
    +							<td>Pretty dump of a variable.
    +								This is handy for debugging modules.
    +								The output will appear in the main Allsky log file.</td>
    +						</tr>
    +						<tr>
    +							<td>getSetting(settingName)</td>
    +							<td>Gets a setting from the camera settings file.</td>
    +						</tr>
    +						<tr>
    +							<td>writeDebugImage(module, fileName, image)</td>
    +							<td>Writes a debug image to the
    +								<span class="fileName">${ALLSKY_TMP}/debug/{module}</span> folder.
    +								This can be useful when debugging a module to check the output
    +								at various stages.</td>
    +						</tr>
    +						<tr>
    +							<td>startModuleDebug(module)</td>
    +							<td>Creates the debug directories for a module.</td>
    +						</tr>
    +						<tr>
    +							<td>convertPath(path)</td>
    +							<td>Replaces Allsky variables in a string and is useful if a
    +								module enters a directory based upon any of the Allsky variables.
    +								For example,
    +								<span class="fileName">${ALLSKY_TMP}/allskydata.json</span>
    +								will have the ${ALLSKY_TMP} variable replaced with its real path.
    +								This function can be used to replace any of the Allsky variables
    +								in a string.</td>
    +						</tr>
    +						<tr>
    +							<td>checkAndCreatePath(filePath)</td>
    +							<td>Checks if the passed file exists and if not creates it.</td>
    +						</tr>
    +						<tr>
    +							<td>checkAndCreateDirectory(filePath)</td>
    +							<td>Checks if the passed directory exists and if not creates it.</td>
    +						</tr>
    +						<tr>
    +							<td>raining()</td>
    +							<td>Only available if the <span class="moduleName">Rain</span>
    +								module is being used -
    +								returns a boolean flags to indicate the rain state.</td>
    +						</tr>
    +						<tr>
    +							<td>convertLatLon(input)</td>
    +							<td>Converts the string "52.2S" to decimal -52.2.</td>
    +						</tr>
    +						<tr>
    +							<td>setLastRun(module)</td>
    +							<td>Sets the last run time for a module.
    +								Useful for where a module only needs to run periodically.</td>
    +						</tr>
    +						<tr>
    +							<td>shouldRun(module, period)</td>
    +							<td>Determines if a module should run based on the period.</td>
    +						</tr>
    +						<tr>
    +							<td>dbAdd(key, value)</td>
    +							<td>Adds the key/value pair to the internal database.</td>
    +						</tr>
    +						<tr>
    +							<td>dbUpdate(key, value)</td>
    +							<td>Updates the key/value pair in the internal database.</td>
    +						</tr>
    +						<tr>
    +							<td>isFileWriteable(fileName)</td>
    +							<td>Determines if the file is writeable.</td>
    +						</tr>
    +						<tr>
    +							<td>isFileReadable(fileName)</td>
    +							<td>Determines if the file is readable.</td>
    +						</tr>
    +						<tr>
    +							<td>saveExtraData(fileName, extraData)</td>
    +							<td>Saves the extraData in fileName.</td>
    +						</tr>
    +						<tr>
    +							<td>deleteExtraData(fileName)</td>
    +							<td>Deletes the extra data fileName.</td>
    +						</tr>
    +						<tr>
    +							<td>getGPIOPin(pin)</td>
    +							<td>Returns the board.Pin from the passed in pin (int).</td>
    +						</tr>
    +						<tr>
    +							<td>createTempDir(path)</td>
    +							<td>Creates a temporary directory.</td>
    +						</tr>
    +						<tr>
    +							<td>cleanupModule(moduleData)</td>
    +							<td>Removes any files or environment variables for a module.</td>
    +						</tr>
    +					</table>
    +				</details>
    +			</div>
    +
    +
    +
    +		</div><!-- Layout-main -->
    +	</div><!-- Layout ... -->
     </body>
     
     </html>
    diff --git a/html/documentation/overlays/font-manager.png b/html/documentation/overlays/font-manager.png
    index 832d525df..dce6706e2 100755
    Binary files a/html/documentation/overlays/font-manager.png and b/html/documentation/overlays/font-manager.png differ
    diff --git a/html/documentation/overlays/overlay-manager-add-adv.png b/html/documentation/overlays/overlay-manager-add-adv.png
    new file mode 100755
    index 000000000..bf572e701
    Binary files /dev/null and b/html/documentation/overlays/overlay-manager-add-adv.png differ
    diff --git a/html/documentation/overlays/overlay-manager-add.png b/html/documentation/overlays/overlay-manager-add.png
    new file mode 100755
    index 000000000..8b5c4116d
    Binary files /dev/null and b/html/documentation/overlays/overlay-manager-add.png differ
    diff --git a/html/documentation/overlays/overlay-manager-config.png b/html/documentation/overlays/overlay-manager-config.png
    new file mode 100755
    index 000000000..8a052f359
    Binary files /dev/null and b/html/documentation/overlays/overlay-manager-config.png differ
    diff --git a/html/documentation/overlays/overlay-manager-full.png b/html/documentation/overlays/overlay-manager-full.png
    new file mode 100755
    index 000000000..8fd46055d
    Binary files /dev/null and b/html/documentation/overlays/overlay-manager-full.png differ
    diff --git a/html/documentation/overlays/overlay-manager-team.png b/html/documentation/overlays/overlay-manager-team.png
    new file mode 100755
    index 000000000..79bbcd822
    Binary files /dev/null and b/html/documentation/overlays/overlay-manager-team.png differ
    diff --git a/html/documentation/overlays/overlay-manager.png b/html/documentation/overlays/overlay-manager.png
    new file mode 100755
    index 000000000..14c16f712
    Binary files /dev/null and b/html/documentation/overlays/overlay-manager.png differ
    diff --git a/html/documentation/overlays/overlay-settings-editor.png b/html/documentation/overlays/overlay-settings-editor.png
    index caeaae3ff..b82c1f9ee 100644
    Binary files a/html/documentation/overlays/overlay-settings-editor.png and b/html/documentation/overlays/overlay-settings-editor.png differ
    diff --git a/html/documentation/overlays/overlay-settings-layout.png b/html/documentation/overlays/overlay-settings-layout.png
    index 717d7733e..25e23da7b 100644
    Binary files a/html/documentation/overlays/overlay-settings-layout.png and b/html/documentation/overlays/overlay-settings-layout.png differ
    diff --git a/html/documentation/overlays/overlay-settings-overlays.png b/html/documentation/overlays/overlay-settings-overlays.png
    new file mode 100755
    index 000000000..8e203a5d7
    Binary files /dev/null and b/html/documentation/overlays/overlay-settings-overlays.png differ
    diff --git a/html/documentation/overlays/overlay-window.png b/html/documentation/overlays/overlay-window.png
    index cbdde459b..991228f3a 100755
    Binary files a/html/documentation/overlays/overlay-window.png and b/html/documentation/overlays/overlay-window.png differ
    diff --git a/html/documentation/overlays/overlays.html b/html/documentation/overlays/overlays.html
    index e8f11f6ea..b68e67535 100755
    --- a/html/documentation/overlays/overlays.html
    +++ b/html/documentation/overlays/overlays.html
    @@ -32,24 +32,23 @@
     				This often includes the time the image was taken, the sensor temperature,
     				and the exposure length, but can include any other text or images you want.
     			</p>
    -			<p>Allsky has always had the ability to add <em>text</em> to the captured image but in a very limited way.
    +			<p>Allsky has always had the ability to add <em>text</em>
    +				to the captured image but in a very limited way.
     				There a new method to add an overlay.
    -				You choose the method via the <span class="WebUISetting">Overlay Method</span> setting
    -				in the WebUI's <span class="WebUILink">Allsky Settings</span> page.
    +				You choose the method via the <span class="WebUISetting">Overlay Method</span>
    +				setting in the WebUI's <span class="WebUILink">Allsky Settings</span> page.
     			<ol>
    -				<li>The new <span class="WebUIValue">module</span>
    -					method has many new features and is significantly more flexible than the older method.
    +				<li>The <span class="WebUIValue">module</span> method has many new
    +					features and is significantly more flexible than the older method.
     					For example, you can add an overlay ONLY to the live image but not to saved images.
     					You can also easily add images and text using a drag-and-drop method.
    -				<li>The <span class="WebUIValue">legacy</span> method
    -					allows only text to be placed in a single location on an image with limited formatting.
    -					See the <a allsky="true" external="true" href="overlaysLegacy.html">Legacy Overlay Method</a>
    -					page for details on that method.
    +				<li>The <span class="WebUIValue">legacy</span> method allows only text
    +					text to be placed in a single location on an image with limited formatting.
     					<br>This page focuses on the <span class="WebUIValue">module</span> method.
     			</ol>
     			<blockquote>
    -				The <span class="WebUIValue">module</span> method will be the default in the next version of Allsky,
    -				and will be the ONLY method in the version after that.
    +				The <span class="WebUIValue">module</span> method is the default
    +				and will be the ONLY method in the next version of Allsky.
     				<br>We suggest you start using this method now.
     			</blockquote>
     			</p>
    @@ -66,18 +65,20 @@ <h2>Features</h2>
     					<li><span class="editorName">Overlay Editor</span> - this is the web page for creating
     						and managing overlays and supports:
     						<ul class="minimalPadding">
    -							<li><strong>Drag and Drop interface</strong> - Fields can be dragged around the screen
    -								to position them.</li>
    +							<li><strong>Drag and Drop interface</strong>
    +								- Fields can be dragged around the screen to position them.</li>
     							<li><strong>Customisable Interface</strong> - The
     								<span class="editorName">Overlay Editor</span>
     								user interface can be highly customised.
     							</li>
    -							<li><span class="managerName">Font Manager</span> - You can upload any TrueType font and use
    +							<li><span class="managerName">Font Manager</span>
    +								- You can upload any TrueType font and use
     								it in the overlays or use any font already on your Pi.</li>
    -							<li><span class="managerName">Variable Manager</span> - Provides a library of fields that
    -								you
    +							<li><span class="managerName">Variable Manager</span>
    +								- Provides a library of fields that you
     								can add to the image. You can also add your own fields.</li>
    -							<li><span class="managerName">Image Manager</span> - Allows you to upload and manage images
    +							<li><span class="managerName">Image Manager</span>
    +								- Allows you to upload and manage images
     								you wish to add to the image.</li>
     						</ul>
     					</li>
    @@ -168,7 +169,7 @@ <h2>Fields and Variables</h2>
     				See the <a href="#ValidVariableNames">Variable names</a> section for details on variable names.
     			</p>
     			<p>There are two types of fields:
    -			<ol>
    +			<ol class="minimalPadding">
     				<li><strong>Text</strong> fields contain text and/or variables, for example,
     					<code class="noWrap">Exposure: ${sEXPOSURE}</code>,
     					which adds the word <code class="noWrap">Exposure: </code>
    @@ -212,7 +213,7 @@ <h2>Fields and Variables</h2>
     						<strong>Allsky Variables</strong> tab.
     						<br>Undefined variables will have a line in
     						<span class="fileName">/var/log/allsky.log</span> like:
    -						<code class="noWrap">ERROR: ${T2} has no variable type; check 'fields.json'</code>.
    +						<code class="noWrap">ERROR: ${T2} has no variable type</code>.
     						<p>
     							If a variable is displayed as <code>??</code> it usually means
     							the variable's formatting is incorrect, for example,
    @@ -240,8 +241,9 @@ <h2>Fields and Variables</h2>
     								<td><code>${DATE}</code></td>
     								<td>24/10/2023</td>
     								<td>Displays the date from the DATE system variable.
    -									The date can be <a href=#formattingVariables">formatted in a variety of ways</a>.
    -									<br>This field contains only a variable.
    +									The date can be
    +									<a href="#formattingVariables">formatted in a variety of ways</a>.
    +									<br>This example field contains only a variable.
     								</td>
     							</tr>
     							<tr>
    @@ -278,8 +280,8 @@ <h2>Fields and Variables</h2>
     
     					<p class="morePadding">Variables come from a variety of sources:</p>
     					<ol class="minimalPadding">
    -						<li><strong>Allsky</strong> - The main Allsky application generates system variables.
    -							Also called "system" variables because they are produced by the Allsky system.</li>
    +						<li><strong>Allsky</strong> -
    +							The main Allsky application generates many variables.</li>
     						<li><strong>Modules</strong> - Any module can create variables.</li>
     						<li><strong>Extra Data</strong> - Typically created by an application external to Allsky.
     						</li>
    @@ -342,7 +344,7 @@ <h3>1. Allsky variables</h3>
     								</tr>
     								<tr>
     									<td><code>${sEXPOSURE}</code></td>
    -									<td>218 ms (0.2 sec)</td>
    +									<td>218 ms (0.2&nbsp;sec)</td>
     									<td>The exposure of the image in a human readable format.
     										The format changes depending on the exposure time,
     										for example, very short exposures may be
    @@ -380,13 +382,6 @@ <h3>1. Allsky variables</h3>
     										Values for RPi cameras are from 0.0 (pure black) to 1.0 (pure white).
     									</td>
     								</tr>
    -								<tr>
    -									<td><code>${BRIGHTNESS}</code></td>
    -									<td>0</td>
    -									<td>The brightness per the
    -										<span class="WebUISetting">Brightness</span> setting in the WebUI.
    -									</td>
    -								</tr>
     							</tbody>
     						</table>
     						<hr class="separatorMinor">
    @@ -401,9 +396,9 @@ <h3>2. Module variables</h3>
     						variable called <code>${STARCOUNT}</code> and passes it to the next module.
     					</p>
     					<p>Please refer to the
    -						<a allsky="true" external="true" href="/documentation/modules/modules.html">documentation on
    -							each module</a>
    -						for the variables they makes available.
    +						<a allsky="true" external="true" href="/documentation/modules/modules.html">
    +							documentation on each module</a>
    +						for the variables they make available.
     					</p>
     
     					<h3>3. Extra Data variables - advanced topic</h3>
    @@ -419,17 +414,19 @@ <h3>3. Extra Data variables - advanced topic</h3>
     							page should provide the understanding you need.
     						</blockquote>
     						<p class="morePadding">As an example, assume you want to add weather data to your images.
    -							You first need to create or obtain a program that gathers that data and writes it to a file.
    +							You first need to create or obtain a program that gathers
    +							that data and writes it to a file.
     							How you obtain that file is outside the scope of this documentation,
     							but the program needs to write the data in a specific format in a file called
     							<span class="fileName">~/allsky/config/overlay/extra/xxxxx</span>
     							(replace the <span class="fileName">xxxxx</span> with an appropriate name).
     						</p>
     						<p>
    -							The "extra" file can be either a simple <span class="fileName">.txt</span>
    +							The "extra" file can be either a simple
    +							<span class="fileName">.txt</span>
     							file or preferably a <span class="fileName">.json</span>
     							file since it provides much more flexibility.
    -							You can have multiple "extra" files (with different names);
    +							You can have multiple "extra" files with different names;
     							this can be useful if you want to add different types of data to the overlay,
     							and each type has its own program to gather the data.
     							A typical example is weather data and dew heater status.
    @@ -467,7 +464,7 @@ <h4>Text Files</h4>
     								</tr>
     							</tbody>
     						</table>
    -						<p>The data in these files could become 'old' if the application creating them
    +						<p>The data in these files could become old if the application creating them
     							fails or is not running. To have Allsky detect this you tell it
     							to ignore "extra" files when they are over a certain age.
     							For <span class="fileName">.txt</span> files there is a single value which is specified
    @@ -622,15 +619,18 @@ <h4>JSON Files</h4>
     								The variables in the examples above are prefixed with <code>AG_</code>.
     								You can use anything except <code>AS_</code> and <code>ALLSKY_</code>.
     							</li>
    -							<li><strong>Variable values</strong> should generally not include units.
    -								For example <code>${DOME_TEMPERATURE}</code> should be <code>20.72</code>,
    +							<li><strong>Variable values</strong> should generally not include units
    +								of measure.
    +								For example <code>${DOME_TEMPERATURE}</code> should be
    +								<code>20.72</code>,
     								not <code>20.72&deg; C</code> because <code>20.72</code> is a "Numeric"
     								variable that can be formated (e.g., to <code>20.7</code>) whereas
     								<code>20.72&deg; C</code> is a "Text" string that can't be formatted.
    -								If you want <code>&deg; C</code> to appear on the overlay, add it in the field itself:
    -								<br>
    -								<code>Dome temperature: ${DOME_TEMPERATURE}&amp;deg; C</code>.
    -								<br>Note that <code>&amp;deg;</code> add the degree symbol &deg;.
    +								If you want <code>&deg; C</code>
    +								to appear on the overlay, add it in the field itself:
    +								<code class="nowrap">Dome temperature: ${DOME_TEMPERATURE}&amp;deg; C</code>.
    +								<br>Note that <code>&amp;deg;</code> adds the degree symbol
    +								<code>&deg;</code>.
     							</li>
     							<li><strong>Permissions</strong> - You must ensure that the "extra" files
     								can be read by the web server.
    @@ -656,6 +656,7 @@ <h2>The Overlay Editor User Interface</h2>
     					<li>The <strong>working area</strong> contains the overlay and the current image
     						in the background.</li>
     				</ol>
    +				<blockquote><strong>NOTE:</strong> The Overlay Editor will only start if Allsky is capturing images. If Allsky is not capturing a warning is displayed and the Overlay Editor will wait until Allsky start capturing.</blockquote>
     				<details>
     					<summary></summary>
     					<blockquote class="morePadding">
    @@ -759,8 +760,8 @@ <h2>The Overlay Editor User Interface</h2>
     								<td class="overlayIcon"><i class="fa fa-regular fa-square-check fa-lg"></i></td>
     								<td>
     									<p>Click to display sample data in each of the fields. This is
    -										useful to see what actual data will look like on the overlay so you can
    -										better align fields.</p>
    +										useful to see what actual data will look like
    +										on the overlay so you can better align fields.</p>
     								</td>
     							</tr>
     							<tr>
    @@ -805,65 +806,98 @@ <h2>The Overlay Editor User Interface</h2>
     									<p>Click to zoom to fit the image on the screen.</p>
     								</td>
     							</tr>
    -							<!-- TODO: add "fa-solid fa-bug"  for "Debug Info" ??? -->
     							<tr>
     								<td class="moduleAnnotation">
     									<span class="moduleToolbarIconNumber">11</span>
    +									<span class="moduleToolbar">Overlay Manager</span>
    +								</td>
    +								<td class="overlayIcon"><i
    +										class="fa fa-solid fa-gears fa-lg"></i></td>
    +								<td>
    +									<p>Displays the Overlay Manager.</p>
    +								</td>
    +							</tr>							
    +							<tr>
    +								<td class="moduleAnnotation">
    +									<span class="moduleToolbarIconNumber">12</span>
    +									<span class="moduleToolbar">Field Errors</span>
    +								</td>
    +								<td class="overlayIcon"><i class="fa fa-solid fa-circle-exclamation fa-lg"></i></td>
    +								<td>
    +									<p><strong>Only</strong> displayed if any fields are
    +									detected that are off the screen.
    +									Selecting this icon will display a dialog allowing the
    +									off-screen fields to be deleted or fixed.
    +									Fixing the field will move it into the visible portion
    +									of the overlay editor allowing you to move it to
    +									the exact position required.</p>
    +								</td>
    +							</tr>
    +							<tr>
    +								<td class="moduleAnnotation">
    +									<span class="moduleToolbarIconNumber">13</span>
     									<span class="moduleToolbar">Font Manager</span>
     								</td>
     								<td class="overlayIcon"><i class="fa fa-solid fa-download fa-lg"></i></td>
     								<td>
     									<p>Displays the <span class="managerName">Font Manager</span>.
    -										See the <a href="#fontManager">Font Manager</a> section for more details.
    +										See the <a href="#fontManager">Font Manager</a>
    +										section for more details.
     									</p>
     								</td>
     							</tr>
     							<tr>
     								<td class="moduleAnnotation">
    -									<span class="moduleToolbarIconNumber">12</span>
    +									<span class="moduleToolbarIconNumber">14</span>
     									<span class="moduleToolbar">Image Manager</span>
     								</td>
     								<td class="overlayIcon"><i class="fa fa-regular fa-images fa-lg"></i></td>
     								<td>
     									<p>Displays the <span class="managerName">Image Manager</span>.
    -										See the <a href="#imageManager">Image Manager</a> section for more details.
    +										See the <a href="#imageManager">Image Manager</a>
    +										section for more details.
     									</p>
     								</td>
     							</tr>
     							<tr>
     								<td class="moduleAnnotation">
    -									<span class="moduleToolbarIconNumber">13</span>
    +									<span class="moduleToolbarIconNumber">15</span>
     									<span class="moduleToolbar">Layout and App Options</span>
     								</td>
     								<td class="overlayIcon"><i class="fa fa-solid fa-gear fa-lg"></i></td>
     								<td>
    -									<p>Displays the <span class="editorName">Overlay Editor</span> settings dialog
    -										which allows you to change settings of the manager itself.
    -										See the <a href="#overlayEditorSettings">Overlay Editor Settings</a>
    +									<p>Displays the <span class="editorName">Overlay Editor</span>
    +										settings dialog which allows you to change settings
    +										of the manager itself.  See the
    +										<a href="#overlayEditorSettings">Overlay Editor Settings</a>
     										section for more details.</p>
     								</td>
     							</tr>
     							<tr>
     								<td class="moduleAnnotation">
    -									<span class="moduleToolbarIconNumber">14</span>
    -									Working Area
    +									<span class="moduleToolbarIconNumber">16</span>
    +									<span class="moduleToolbar">Working Area</span>
     								</td>
     								<td></td>
     								<td>
     									<p>The main working area.
     										The image displayed is the last one captured by Allsky.</p>
    -									<blockquote>It's best to create overlays when Allsky is running
    +									<blockquote>
    +										It's best to create overlays when Allsky is running
     										since you'll see the latest image.
    -										You can create overlays when Allsky is not running but bear in
    -										mind that Notification images (e.g., "Allsky is starting") are usually
    +										You can create overlays when Allsky is not running
    +										but in mind that Notification images
    +										(e.g., "Allsky is starting") are usually
     										smaller than those captured by your camera so
     										you may not be using all of the available screen area.
    -										<p class="morePadding">Ideally you will create overlays after you've set any
    -											resize and crop options and the captured images size is its
    -											"final" size.</p>
    +										<p class="morePadding">
    +											Ideally you will create overlays after you've set any
    +											resize and crop options and the captured
    +											image's size is finalized.</p>
     									</blockquote>
     								</td>
     							</tr>
    +							
     						</tbody>
     					</table>
     					<hr class="separatorSmall">
    @@ -876,7 +910,204 @@ <h2>Using The Overlay Editor</h2>
     				<span class="editorName">Overlay Editor</span> and how to manage fields and their contents.
     			</p>
     			<details>
    +
    +				
     				<summary></summary>
    +				<h3 id="overlayManager">The Overlay Manager &nbsp; <i class="fas fa-gears"></i>
    +				</h3>				
    +				<p>The <span class="managerName">Overlay Manager</span> is used to create and enable overlays for day and nighttime capture.
    +				</p>
    +				<details sub>
    +					
    +					
    +					<p>The Overlay Manager comes pre installed with several overlays for common cameras. The Allsky team maintain these overlays. During installation the most appropriate overlay for your camera will have been selected, the Overlay Manager allows you to use these overlays or create a new one.</p>
    +					
    +					<blockquote>The Allsky maintained overlays cannot be edited, you must create a new overlay if you wish to edit it. The new overlay can be copied from any exising overlay</blockquote>
    +					
    +					<blockquote>During an upgrade from a previous version of Allsky the Module Manager will attempt to convert any of your customised overlays into the module manager format</blockquote>
    +					
    +					<table class="morePadding">
    +						<tbody>
    +							<tr>
    +								<td width="25%"><img allsky="true" src="overlay-manager-full.png" alt="The Main Overlay Manager Window" loading="lazy" class="imgCenter" /></td>
    +								<td>
    +									<h4>The Overlay Manager</h4>
    +									<p>To open the Overlay Manager select the <i class="fas fa-gears"></i> icon from the main toolbar</p>
    +								</td>
    +							</tr>
    +							<tr>
    +								<td><img allsky="true" src="overlay-manager.png" alt="The Main Overlay Manager Window" loading="lazy" class="imgCenter" /></td>
    +								<td>
    +									<h4>The Overlay Manager main tab</h4>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">1</span>
    +										<span class="moduleToolbar">Overlay</span>
    +										<span>Selects the main overlay manager tab</span>
    +									</p>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">2</span>
    +										<span class="moduleToolbar">Config</span>
    +										<span>Displays the tab to allow overlays to be activated</span>
    +									</p>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">3</span>
    +										<span class="moduleToolbar">Overlay</span>
    +										<span>Selects the overlay to display/edit. When selecting an overlay the overlay will be displayed in the main overlay editor</span>
    +										<span>If you select an overlay that cannot be edited then a warning is displayed in the main overlay editor</span>
    +										<img allsky="true" src="overlay-manager-team.png" alt="Overlay Manager Warning" loading="lazy" class="imgCenter" />
    +									</p>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">4</span>
    +										<span class="moduleToolbar">Add Overlay</span>
    +										<span>Adds a new overlay</span>
    +									</p>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">5</span>
    +										<span class="moduleToolbar">Delete Overlay</span>
    +										<span>Deletes an overlay. <strong>NOTE:</strong> Allsky maintained overlays cannot be deleted</span>
    +									</p>
    +									<p>The remaining options on the Overlay Manager apply to the currently selected overlay, selected in <span class="moduleToolbarIconNumber">3</span></p>
    +									<blockquote>When making any changes in this section you must use the main save button on the Overlay Editor toolbar, this will be highlighted in green if there are any changes to save</blockquote>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">6</span>
    +										<span class="moduleToolbar">Overlay Name</span>
    +										<span>The name of the overlay, this is used to identify the overlay in a human readable format</span>
    +									</p>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">7</span>
    +										<span class="moduleToolbar">Overlay Description</span>
    +										<span>The name of the overlay description</span>
    +									</p>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">8</span>
    +										<span class="moduleToolbar">Camera Brand</span>
    +										<span>The camera brand the overlay applies to</span>
    +									</p>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">9</span>
    +										<span class="moduleToolbar">Camera Model</span>
    +										<span>The camera model the overlay applies to</span>
    +									</p>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">10</span>
    +										<span class="moduleToolbar">Image Width</span>
    +										<span>The width of the captured image, after any cropping</span>
    +									</p>									
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">11</span>
    +										<span class="moduleToolbar">Image Height</span>
    +										<span>The height of the captured image, after any cropping</span>
    +									</p>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">12</span>
    +										<span class="moduleToolbar">Available For</span>
    +										<span>The Time of Day the overlay should be used for</span>
    +									</p>									
    +								</td>
    +							</tr>
    +							<tr>
    +								<td><img allsky="true" src="overlay-manager-config.png" alt="The Main Overlay Manager Window" loading="lazy" class="imgCenter" /></td>
    +								<td>
    +									<h4>The Overlay Manager options tab</h4>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">1</span>
    +										<span class="moduleToolbar">Daytime Overlay</span>
    +										<span>Selects the overlay to use when capturing daytime images</span>
    +									</p>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">2</span>
    +										<span class="moduleToolbar">Nightime Overlay</span>
    +										<span>Selects the overlay to use when capturing nightime images</span>
    +									</p>									
    +								</td>
    +							</tr>							
    +						</tbody>
    +					</table>
    +					
    +					<h4>Selecting an overlay for Day/Night time capture</h4>
    +					<p>To select the overlays to use open the Overlay Manager and switch to the Options Tab. Here you can select the appropriate overlay to use.</p>
    +					<p>Overlays can be limited to daytime, nighttime capture ot both. Only the available overlays for day / nighttime capture will be displayed in the drop downs</p>
    +					
    +					<h4>Creating a new overlay</h4>
    +					<p>To create a new overlay click the <span><i class="fas fa-square-plus"></i></span> icon (<span class="moduleToolbarIconNumber">4</span>). A new dialog will be displayed allowing you to create the new overly.</p>
    +					
    +					<table class="morePadding">
    +						<tbody>
    +							<tr>
    +								<td width="25%"><img allsky="true" src="overlay-manager-add.png" alt="The Main Overlay Add Window" loading="lazy" class="imgCenter" /></td>
    +								<td>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">1</span>
    +										<span class="moduleToolbar">Select Template</span>
    +										<span>Selects the overlay you wish to use as a starting point or select 'Blank Overlay' for an empty overlay</span>
    +									</p>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">2</span>
    +										<span class="moduleToolbar">Name</span>
    +										<span>Enter a Human Readable name for the overlay</span>
    +									</p>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">3</span>
    +										<span class="moduleToolbar">Description</span>
    +										<span>Enter a Human Readable name for the overlay</span>
    +									</p>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">4</span>
    +										<span class="moduleToolbar">Available For</span>
    +										<span>Select the time of day you wish this overlay to be used in</span>
    +									</p>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">5</span>
    +										<span class="moduleToolbar">Activate After Creation</span>
    +										<span>If selected then the overlay will be activated after it has been created. If not selected the overlay will be saved as a draft and you will have to manually activate it.</span>
    +									</p>
    +									<blockquote>By default when an overlay is created it is activated for day/night or both as defined by the 'Available For' dropdown. If you DO NOT want the overlay to be activated by default then select 'No' in the 'Activate  After Creation' option.</blockquote>
    +								</td>
    +							</tr>
    +							<tr>
    +								<td><img allsky="true" src="overlay-manager-add-adv.png" alt="The Main Overlay Add Advanced Window" loading="lazy" class="imgCenter" /></td>
    +								<td>
    +									<blockquote>You will only need to make changes on this tab if you are creating an empty overlay. If you are copying an existing overlay then all of the fields will be defaulted from the copied overlay.</blockquote>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">1</span>
    +										<span class="moduleToolbar">Filename</span>
    +										<span>This is for informatio only and is the filename of the overlay that will be used to store it on the Pi</span>
    +									</p>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">2</span>
    +										<span class="moduleToolbar">Camera Brand</span>
    +										<span>The camera brand the overlay applies to</span>
    +									</p>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">3</span>
    +										<span class="moduleToolbar">Camera Model</span>
    +										<span>The camera model the overlay applies to</span>
    +									</p>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">4</span>
    +										<span class="moduleToolbar">Image Width</span>
    +										<span>The width of image the overlay applies to. This will default to the width of the last captured image</span>
    +									</p>
    +									<p class="moduleAnnotation">
    +										<span class="moduleToolbarIconNumber">5</span>
    +										<span class="moduleToolbar">Image Height</span>
    +										<span>The height of image the overlay applies to. This will default to the width of the last captured image</span>
    +									</p>									
    +								</td>
    +							</tr>
    +						</tbody>
    +					</table>
    +					
    +					<p>Once you have completed the required fields in the dialog select the 'Ok' button and the new overlay will be displayed allowing you to edit it.</p>
    +					
    +					<h4>Deleting an overlay</h4>
    +					<p>To delete an overlay select the required overlay and click the delete buttton.</p>
    +					<blockquote>NOTE: You cannot delete an overlay if it is currenty active. You must activate another overlay before deleting it.</blockquote>
    +					<summary></summary>
    +
    +				</details>
    +				
    +
     				<h3 id="variableManager">The Variable Manager &nbsp; <i class="fa fa-regular fa-rectangle-list"></i>
     				</h3>
     				<p>The <span class="managerName">Variable Manager</span> is used to store
    @@ -938,7 +1169,7 @@ <h4>Allsky Variables tab</h4>
     										add your own variable and set the variable's Type and other attributes.
     									<blockquote>
     										This button does NOT add the variable to the overlay;
    -										instead, it just adds it to the end of the
    +										instead, it adds it to the end of the
     										<strong>Allsky Variables</strong> list so it can be added to the overlay.
     									</blockquote>
     									</p>
    @@ -1015,7 +1246,7 @@ <h4>The "Add Variable" and "Edit Variable" dialog boxes</h4>
     									<ul>
     										<li><strong>Variable Name</strong> - The name of the variable
     											enclosed within <code><strong>${}</strong></code>.
    -											For system variables the name cannot be changed.
    +											Allsky variables' names cannot be changed.
     											See the <a href="#ValidVariableNames">Variable names</a>
     											section for details on valid variable names.
     										</li>
    @@ -1068,35 +1299,36 @@ <h3 id="fontManager">The Font Manager &nbsp; <i class="fa fa-solid fa-download">
     								</tr>
     								<tr>
     									<td>
    -										<ul>
    -											<li><span class="moduleToolbarIconNumber">1</span>
    -												<strong>Installed Fonts</strong> - Lists all the fonts that can be
    -												used.
    -												System Fonts are marked as such in the <strong>Path</strong> column.
    -											</li>
    -											<li><span class="moduleToolbarIconNumber">2</span>
    -												<strong>Delete Button</strong> - Deletes a font.
    -												<blockquote>It is not possible to delete a System Font and they
    -													will not have a delete icon as shown for the first several fonts
    -													above.
    -												</blockquote>
    -												<blockquote>If a font is in use when deleted then any
    -													fields using the font will revert to the default font as
    -													specified in the overlay settings.
    -												</blockquote>
    -											</li>
    -											<li><span class="moduleToolbarIconNumber">3</span>
    -												The <span class="btn btn-primary btn-small">Add Font</span>
    -												button adds a font from
    -												<a external="true" href="daFont.com">daFont.com</a> by entering a URL.
    -												See <a href="#dafont">below</a>.
    -											</li>
    -											<li><span class="moduleToolbarIconNumber">4</span>
    -												The <span class="btn btn-primary btn-small">Upload Font</span> button
    -												uploads a zip file to install fonts.
    -												See <a href="#zip">below</a>.
    -											</li>
    -										</ul>
    +
    +										<p><span class="moduleToolbarIconNumber">1</span>
    +											<strong>Installed Fonts</strong> - Lists all the fonts that can be used. System Fonts are marked as such in the <strong>Path</strong> column.
    +										</p>
    +										<p><span class="moduleToolbarIconNumber">2</span>
    +											<strong>Delete Button</strong> - Deletes a font.
    +											<blockquote>It is not possible to delete a System Font and they
    +												will not have a delete icon as shown for the first several fonts
    +												above.
    +											</blockquote>
    +											<blockquote>If a font is in use when deleted then any
    +												fields using the font will revert to the default font as
    +												specified in the overlay settings.
    +											</blockquote>
    +										</p>
    +										<p><span class="moduleToolbarIconNumber">3</span>
    +											<strong>Use/Delete Button</strong> - Enables the font for use in this overlay or removes it from the overlay
    +										</p>								
    +										<p><span class="moduleToolbarIconNumber">4</span>
    +											The <span class="btn btn-primary btn-small">Add Font</span>
    +											button adds a font from
    +											<a external="true" href="https://daFont.com">daFont.com</a> by entering a URL.
    +											See <a href="#dafont">below</a>.
    +										</p>
    +										<p><span class="moduleToolbarIconNumber">5</span>
    +											The <span class="btn btn-primary btn-small">Upload Font</span> button
    +											uploads a zip file to install fonts.
    +											See <a href="#zip">below</a>.
    +										</p>
    +
     									</td>
     								</tr>
     							</tbody>
    @@ -1110,10 +1342,10 @@ <h3 id="fontManager">The Font Manager &nbsp; <i class="fa fa-solid fa-download">
     							<li id="dafont"><strong>Add font from daFont.com</strong>
     								<br>
     								In a separate browser window, navigate to the font page on
    -								<a external="true" href="http://daFont.com">http://daFont.com</a>
    +								<a external="true" href="https://daFont.com">daFont.com</a>
     								that you wish to install, for example
     								<a external="true"
    -									href="https://www.dafont.com/led-sled.font">https://www.dafont.com/led-sled.font</a>.
    +									href="https://www.dafont.com/led-sled.font">www.dafont.com/led-sled.font</a>.
     								Copy the font URL to the clipboard.
     								Click the <span class="btn btn-primary btn-small">Add Font</span> button and enter
     								the font URL.
    @@ -1124,15 +1356,23 @@ <h3 id="fontManager">The Font Manager &nbsp; <i class="fa fa-solid fa-download">
     								<br>
     								First ensure that the font(s) you wish to installed are contained within a zip file.
     								You can create the zip file yourself or download from a place like
    -								<a external="true" href="https://fonts.google.com">https://fonts.google.com/</a>.
    +								<a external="true" href="https://fonts.google.com">fonts.google.com/</a>.
     								Click the <span class="btn btn-primary btn-small">Upload Font</span>
     								button and browse to the zip file.
     								The fonts will be extracted from the zip file and installed.</p>
    -								<blockquote><p>NOTE: If you receive an error when uploading a font please check the php max
    -									file upload size. By default this is 2Mb which may be too small for some font zip
    -									files</p>
    -								<p>The php configuration file is normally <span class="fileName">/etc/php/7.4/cgi/php.ini</span>. Edit this file and locate the line containing 'upload_max_filesize =' and change the value. So if the exiting line says 'upload_max_filesize = 2MB' change it to 'upload_max_filesize = 20MB'</p>
    -								<p>Following this change either restart lighttpd with <pre>sudo systemctl restart lighttpd</pre></p>
    +								<blockquote>
    +								NOTE: If you receive an error when uploading a font
    +								please check the php max file upload size.
    +								By default this is 2 MB which may be too small for some font zip
    +								files</p>
    +								<p>The php configuration file is normally
    +								<span class="fileName">/etc/php/8.2/cgi/php.ini</span> but will vary depending upon the version of php you have install. Edit this file and locate the line containing
    +								<code>upload_max_filesize =</code> and change the value.
    +								So if the exiting line says
    +								<code>upload_max_filesize = 2MB</code> change it to
    +								<code>upload_max_filesize = 20MB</code></p>
    +								Following this change restart the web server:
    +								<pre>sudo systemctl restart lighttpd</pre>
     								</blockquote>
     							</li>
     						</ol>
    @@ -1157,32 +1397,36 @@ <h3 id="imageManager">The Image Manager &nbsp; <i class="fa fa-regular fa-images
     									<td>
     										<ul>
     											<li><span class="moduleToolbarIconNumber">1</span>
    -												&nbsp; <i class="fa fa-solid fa-image fa-lg"></i> &nbsp;
    -												<strong>Add Selected Image</strong> - Adds the selected
    -												image to the overlay. The selected image has a
    -												<span style="border: 1px solid red;">red</span> border
    -												like the 3rd image above.
    +												&nbsp; <i class="fa fa-solid fa-image fa-lg"></i>
    +												&nbsp; <strong>Add Selected Image</strong> -
    +												Adds the selected image to the overlay.
    +												The selected image has a
    +												<span style="border: 1px solid red;">red</span>
    +												border like the 3rd image above.
     											</li>
     											<li><span class="moduleToolbarIconNumber">2</span>
    -												&nbsp; <i class="fa fa-solid fa-trash fa-lg"></i> &nbsp;
    -												<strong>Delete The Selected Image</strong> - Deletes the selected
    -												image from the overlay.
    -												Images that are grayed out like the 4th one above,
    +												&nbsp; <i class="fa fa-solid fa-trash fa-lg"></i>
    +												&nbsp; <strong>Delete The Selected Image</strong> -
    +												Deletes the selected image from the overlay.
    +												Images that are grayed out like the 4th one above
     												are in use in an overlay and cannot be deleted.
    -												<blockquote>When deleting an image it will also be deleted
    -													from the Pi.
    -													If you want the image to remain on the Pi, select its field
    -													in the <span class="editorName">Overlay Editor</span>
    +												<blockquote>
    +													When deleting an image it will also
    +													be deleted from the Pi.
    +													If you want the image to remain on the Pi,
    +													select its field in the
    +													<span class="editorName">Overlay Editor</span>
     													and delete it.
     												</blockquote>
     											</li>
     											<li><span class="moduleToolbarIconNumber">3</span>
    -												<strong>Uploaded images area</strong> - Previously uploaded images
    -												that can be selected.
    +												&nbsp; <strong>Uploaded images area</strong> -
    +												Previously uploaded images that can be selected.
     												Not all images need to be used on an overlay.
     											</li>
     											<li><span class="moduleToolbarIconNumber">4</span>
    -												<strong>File upload area</strong> - Either click on this area
    +												&nbsp; <strong>File upload area</strong> -
    +												Either click on this area
     												to upload a new image or drag an image onto it.
     											</li>
     										</ul>
    @@ -1233,6 +1477,7 @@ <h3>Adding fields to the overlay</h3>
     								uploaded via the <span class="managerName">Image Manager</span> as described above.
     							</li>
     						</ol>
    +						<blockquote>Before a field can be displayed it <strong>MUST</strong> be defined in the 'Allsky Variables' tab. If the field is not defined in this tab then it will not be displayed by the overlay manager</blockquote>
     						<hr class="separatorMinor">
     					</details>
     
    @@ -1258,7 +1503,7 @@ <h4>Text Properties Editor</h4>
     											<li><strong>Format</strong> - Certain variable types allow for
     												formatting, including dates, times, numbers, and booleans.
     												Details of the formats that can be entered are covered in the
    -												<a href=#formattingVariables">formatting text section</a>
    +												<a href="#formattingVariables">formatting text section</a>
     												and can also be viewed by clicking on the <code>[?]</code> icon.
     											</li>
     											<li><strong>Sample</strong> - A value that is displayed when the
    @@ -1686,6 +1931,40 @@ <h4>Number formats</h4>
     						</table>
     
     					</details>
    +				
    +					<h3 id="fieldfaq">Field FAQ's</h3>
    +					<p>Answers to some common issues with overlays and fields.</p>
    +					<details sub>
    +						<summary></summary>
    +						<table>
    +							<tr>
    +								<td>Fields have been added to the overlay but are not appearing, the same set of fields appear no matter what changes are mode in the Overlay Manager</td>
    +								<td>Please ensure that the overlay method is set to 'module' in the main Allsky settings</td>
    +							</tr>
    +							<tr>
    +								<td>No overlays are added</td>
    +								<td>Please ensure that the overlay module has been added to the relevant flow, day or night and that its enabled</td>
    +							</tr>
    +						  <tr>
    +								<td>I have added a field but its not appearing</td>
    +								<td>There are several possible reasons for this
    +									<ul>
    +										<li>The field is not in the 'Allsky Variables' tab. Please ensure that the variable has been added</li>
    +										<li>There is no data available for the field. This is most likely due to an external data file not being available. The Allsky debug logs, see below, will contain a warning if this is the case</li>
    +										<li>There is a problem with the formatting options for a field. The Allsky debug logs, see below, will contain a warning if this is the case </li>
    +									</ul>
    +								</td>
    +							</tr>
    +							<tr>
    +								<td>The 'Field Errors' toobar icon is pulsing red.</td>
    +								<td>This means that the Overlay Manager has detected fields that are outside of the image. Clicking the button will allow you to move the fields into the image and then reposition them as rquired.</td>
    +							</tr>
    +						</table>
    +						
    +						<h4>Checking the Allsky debug log</h4>
    +						<p>If you need to look for issue in the Allsky log then first set the debug level to 4 in the main Allsky settings. After Allsky has restrted the logfile can be found in /var/log/allsky.log</p>
    +						<blockquote>Its is not recommend to leave the debug level set to 4 for any extended period of time unlessed asked to do so by the Allsky Team</blockquote>
    +					</details>
     				</div>
     				<hr class="separator">
     			</details>
    @@ -1707,47 +1986,77 @@ <h3 id="layoutDefaultsTab">Layout Defaults tab</h3>
     						<table class="morePadding">
     							<tbody>
     								<tr>
    -									<td align="center">
    -										<img allsky="true" src="overlay-settings-layout.png" alt="Layout Settings"
    -											loading="lazy" />
    +									<td width="30%">
    +										<img allsky="true" src="overlay-settings-layout.png" alt="Layout Settings" loading="lazy" />
     									</td>
    -								</tr>
    -								<tr>
     									<td>
    -										<ul style="padding-top: 0px">
    -											<li><strong>Default Image Opacity</strong>
    -												- The default opacity for new images.</li>
    -											<li><strong>Default Image Rotation</strong>
    -												- The default rotation for new images.</li>
    -											<li><strong>Default Font</strong>
    -												- The default font for new text fields.</li>
    -											<li><strong>Default Font Size</strong>
    -												- The default font size for new text fields.</li>
    -											<li><strong>Default Font Opacity</strong>
    -												- The default font opacity for new text fields.</li>
    -											<li><strong>Default Font Colour</strong>
    -												- The default font color for new text fields.</li>
    -											<li><strong>Default Text Rotation</strong>
    -												- The default rotation for new text fields.</li>
    -											<li><strong>Default Extra Data Expiry</strong>
    -												- The default expiration time in seconds for "extra" data files
    -												that do not specify an expiry time.
    -												This applies to all variables in <span class="fileName">.txt</span>
    -												files
    -												and entries in <span class="fileName">.json</span> files that
    -												don't have an "<span class="json">expires</span>" attribute.</li>
    -											<li><strong>Norad ID's</strong>
    -												- A comma-separated list of NORAD IDs for generating
    -												<a href="#satelliteData">satellite data</a>.
    -											</li>
    -											<li><strong>Include Planets</strong> - When enabled, data for the
    -												planets' positions will be generated as variables.</li>
    -											<li><strong>Include Sun</strong> - When enabled, data for the Sun's
    -												position will be generated as variables.</li>
    -											<li><strong>Include Moon</strong> - When enabled, data for the Moon's
    -												position will be generated as variables.</li>
    -										</ul>
    -									</td>
    +										<p>
    +											<span class="moduleToolbarIconNumber">1</span><strong> Default Image Opacity</strong> - The default opacity for new images.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">2</span>
    +											<strong> Default Image Rotation</strong>- The default rotation for new images.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">3</span>
    +											<strong> Default Font</strong> - The default font for new text fields.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">4</span>
    +											<strong> Default Font Size</strong> - The default font size for new text fields.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">5</span>
    +											<strong> Default Font Opacity</strong> - The default font opacity for new text fields.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">6</span>
    +											<strong> Default Font Colour</strong> - The default font color for new text fields.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">7</span>
    +											<strong> Default Text Rotation</strong> - The default rotation for new text fields.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">8</span>
    +											<strong> Default Stroke Colour</strong> - The default colour used for the font stroke.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">9</span>
    +											<strong>Default Extra Data Expiry</strong>
    +											- The default expiration time in seconds for "extra" data files
    +											that do not specify an expiry time.
    +											This applies to all variables in <span class="fileName">.txt</span>
    +											files
    +											and entries in <span class="fileName">.json</span> files that
    +											don't have an "<span class="json">expires</span>" attribute.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">10</span>
    +											<strong> Expiry Text</strong> - The default text used when a field has expired.
    +										</p>										
    +										<p>
    +											<span class="moduleToolbarIconNumber">11</span>
    +											<strong>Norad ID's</strong>
    +											- A comma-separated list of NORAD IDs for generating
    +											<a href="#satelliteData">satellite data</a>.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">12</span>
    +											<strong>Include Planets</strong> - When enabled, data for the
    +											planets' positions will be generated as variables.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">12</span>
    +											<strong>Include Sun</strong> - When enabled, data for the Sun's
    +											position will be generated as variables.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">14</span>
    +											<strong>Include Moon</strong> - When enabled, data for the Moon's
    +											position will be generated as variables.
    +										</p>
    +								  </td>
     								</tr>
     							</tbody>
     						</table>
    @@ -1771,49 +2080,109 @@ <h3>Editor Settings tab</h3>
     						<table class="two-col morePadding">
     							<tbody>
     								<tr>
    -									<td align="center">
    +									<td width="30%">
     										<img allsky="true" src="overlay-settings-editor.png" alt="Editor Settings"
     											loading="lazy" />
     									</td>
    +									<td>
    +										<p>
    +											<span class="moduleToolbarIconNumber">1</span>
    +											<strong> Show Grid</strong> - When enabled, displays a grid for
    +											easier
    +											alignment of fields on the overlay.
    +											The grid is used to 'snap' fields making their placement easier.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">2</span>
    +											<strong> Grid Size</strong> - The size of the grid in pixels.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">3</span>
    +											<strong> Grid Colour</strong> - The colour to use for the grid.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">4</span>
    +											<strong> Grid Brightness</strong> - The grid brightness.
    +											Values range from 0 (lowest brightness) to 100 (brightest).
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">5</span>
    +											<strong> Show Snap Rectangle</strong> - When enabled, moving a field
    +											will cause a rectangle to be displayed showing where the field will
    +											snap to if dropped.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">6</span><strong> Add List Page Size</strong> - The number of variables to
    +											show per page in the
    +											<span class="managerName">Variable Manager</span>.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">7</span>
    +											<strong> Add Field Brightness</strong> - When adding a new field all
    +											other fields will be set to this brightness to make the new field
    +											easier to see.
    +											Values range from 0 (darkest) to 100 (each field's full brightness).
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">8</span>
    +											<strong> Select Field Brightness</strong> - When selecting a field all
    +											other fields will be set to this opacity to make the new field
    +											easier to see.
    +											Values range from 0 (darkest) to 100 (each field's full brightness).
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">9</span>
    +											<strong> Zoom with Mouse Wheel</strong> - When enabled, scrolling the
    +											mouse wheel will zoom the overlay.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">10</span>
    +											<strong> Background Image Brightness</strong> - Brightness of the
    +											captured image being overlayed.
    +											Lower this if the captured image is bright to make
    +											it easier to see the overlay fields.
    +											Values range from 0 (black background) to
    +											100 (full brightness of captured image).
    +										</p>
    +									</td>
    +								</tr>
    +							</tbody>
    +						</table>
    +
    +						<p class="morePadding">
    +						<blockquote class="morePadding">When changing the brightness settings you may need to click
    +							somewhere in the
    +							overlay in order for the change to take affect.
    +						</blockquote>
    +						</p>
    +
    +					</details>
    +				
    +					<h3>Editor Settings Overlays tab</h3>
    +					<p>The editor settings Overlay displays available overlays and allows them to be viewed or edited.</p>
    +					<details sub>
    +						<summary></summary>
    +						<table class="two-col morePadding">
    +							<tbody>
     								<tr>
    -								<tr>
    +									<td width="30%">
    +										<img allsky="true" src="overlay-settings-overlays.png" alt="Editor Settings Overlays"
    +											loading="lazy" />
    +									</td>
     									<td>
    -										<ul style="padding-top: 0px">
    -											<li><strong>Show Grid</strong> - When enabled, displays a grid for
    -												easier
    -												alignment of fields on the overlay.
    -												The grid is used to 'snap' fields making their placement easier.
    -											</li>
    -											<li><strong>Grid Size</strong> - The size of the grid in pixels.</li>
    -											<li><strong>Grid Colour</strong> - The colour to use for the grid.</li>
    -											<li><strong>Grid Brightness</strong> - The grid brightness.
    -												Values range from 0 (lowest brightness) to 100 (brightest).</li>
    -											<li><strong>Show Snap Rectangle</strong> - When enabled, moving a field
    -												will cause a rectangle to be displayed showing where the field will
    -												snap to if dropped.</li>
    -											<li><strong>Add List Page Size</strong> - The number of variables to
    -												show per page in the
    -												<span class="managerName">Variable Manager</span>.
    -											</li>
    -											<li><strong>Add Field Brightness</strong> - When adding a new field all
    -												other fields will be set to this brightness to make the new field
    -												easier to see.
    -												Values range from 0 (darkest) to 100 (each field's full brightness).
    -											</li>
    -											<li><strong>Select Field Brightness</strong> - When selecting a field all
    -												other fields will be set to this opacity to make the new field
    -												easier to see.
    -												Values range from 0 (darkest) to 100 (each field's full brightness).
    -											</li>
    -											<li><strong>Zoom with Mouse Wheel</strong> - When enabled, scrolling the
    -												mouse wheel will zoom the overlay.</li>
    -											<li><strong>Background Image Brightness</strong> - Brightness of the
    -												captured image being overlayed.
    -												Lower this if the captured image is bright to make
    -												it easier to see the overlay fields.
    -												Values range from 0 (black background) to
    -												100 (full brightness of captured image).</li>
    -										</ul>
    +										<p>
    +											<span class="moduleToolbarIconNumber">1</span>
    +											<strong> Available Overlays </strong> - Displays a table showing all of the overlays available.
    +										</p>
    +										<p>
    +											<span class="moduleToolbarIconNumber">2</span>
    +											<strong> View Overlay </strong> - This icon is displayed when the overlay is not editable, these are overlays provided by the Allsky team. Clicking the button will view the overlay but not allow it to be edited
    +										</p>										
    +										<p>
    +											<span class="moduleToolbarIconNumber">3</span>
    +											<strong> Edit Overlay </strong> - This icon is displayed when the overlay is editable. Clicking the button will view the overlay and allow it to be edited
    +										</p>										
    +
     									</td>
     								</tr>
     							</tbody>
    @@ -1827,6 +2196,8 @@ <h3>Editor Settings tab</h3>
     						</p>
     
     					</details>
    +
    +
     					<hr class="separatorSmall">
     				</div>
     			</details>
    @@ -2202,4 +2573,4 @@ <h3>Satellites (including the ISS and Hubble)</h3>
     </body>
     
     </html>
    -<script> includeHTML(); </script>
    \ No newline at end of file
    +<script> includeHTML(); </script>
    diff --git a/html/documentation/settings/AllskySettingsPage.png b/html/documentation/settings/AllskySettingsPage.png
    index b5ad9a0d9..3aee8b9a9 100644
    Binary files a/html/documentation/settings/AllskySettingsPage.png and b/html/documentation/settings/AllskySettingsPage.png differ
    diff --git a/html/documentation/settings/EditorColors.html b/html/documentation/settings/EditorColors.html
    index 9fec3e1be..579d5386a 100644
    --- a/html/documentation/settings/EditorColors.html
    +++ b/html/documentation/settings/EditorColors.html
    @@ -34,83 +34,123 @@
     
     
     <p>
    -The WebUI's <b>Editor</b> page allows editing Allsky configuration files.
    +The WebUI's <span class="WebUILink">Editor</span> page allows editing Allsky configuration files.
     Items in the editor window are color-coded depending on what they are.
     </p>
     <blockquote>
     HINT: The Editor accepts CTRL-Z to undo actions.
     </blockquote>
     <p>
    -A typical view of a shell file being edited is below, followed by a typical JSON file.
    +<!-- A typical view of a shell file being edited is below, followed by a typical JSON file.
     A description of the color scheme comes last.
    +-->
    +A typical view of a JSON file being edited is below,
    +followed by a description of the color scheme.
     </p>
     
    -<h3>Shell (.sh) Files</h3>
    +<!--
    +<h2>Shell (.sh) Files</h2>
     <br>
     <a allsky="true" class="img" href="EditorPage-sh.png" target="_blank">
     <img allsky="true" src="EditorPage-sh.png" title="Editing .sh file - click to show full version" alt=".sh file" loading="lazy" width="25%">
     </a>
     
    -<h3>JSON (.json) Files</h3>
    +<h2>JSON (.json) Files</h2>
     <br>
    +-->
     <a allsky="true" class="img" href="EditorPage-json.png">
     <img allsky="true" src="EditorPage-json.png" title="Editing .json file - click to show full version" alt=".json file" loading="lazy">
     </a>
     
    -<h3>Color Scheme</h3>
    +<h2>Color Scheme</h2>
     <ul>
    -<li>Setting <b>names</b> look different depending on the file type:
    +<li>Setting <strong>names</strong> look different depending on the file type:
     	<ul>
    -	<li>In <span class="fileName">.sh</span> files the names look
    -		<span class="editorShell">LIKE_THIS</span>.
    -		By convention shell setting names (called shell "variables") are all uppercase
    -		and multi-word names are separated by an underscore (<code>_</code>),
    -		although there are some variables that don't follow that convention.
     	<li>In <span class="fileName">.json</span> files they look
     		<span class="editorSetting">likeThis</span>.
     		By convention if the name includes multiple words,
     		the first letter of the first word is lowercase and subsequent word's first characters
     		are Upper case.
     		Settings names in the file MUST be enclosed in double quotes,
    -		but quotes are omitted in the Allsky documentation for readability.
    +		but quotes around setting names are omitted in the Allsky documentation for readability.
    +<!--
    +	<li>In <span class="fileName">.sh</span> files the names look
    +		<span class="editorShell">LIKE_THIS</span>.
    +		By convention shell setting names (called shell "variables") are all uppercase
    +		and multi-word names are separated by an underscore (<code>_</code>),
    +		although there are some variables that don't follow that convention.
    +-->
     	</ul>
    -<li>Settings <b>value</b> colors are the same in both file types but
    -	vary based on the type of value:
    +<li>Colors for setting <strong>values</strong> vary based on value's type:
     	<ul>
    -	<li><b>Text</b> (anything surrounded by quotes):
    -		<span class="editorString">"sample text"</span>,
    -		<span class="editorString">"123.4"</span>.
    -		Note that a number surrounded by quotes is actually a string, e.g., "123.4".
    +	<li><strong>Text</strong> (anything surrounded by quotes):
    +		<span class="editorString">"sample text"</span>.
    +		Note that a number surrounded by quotes like
    +		<span class="editorString">"41.79"</span> is actually a string.
     		Numbers-as-strings may work in some cases,
     		but when entering a number it's safest to NOT use quotes.
    -	<li><b>Numbers</b> (when not quoted):
    -		<span class="editorNum">1.234</span>,
    -		<span class="editorSign">-</span><span class="editorNum">21</span>,
    -	<li><b>Booleans</b> (when not quoted):
    +	<li><strong>Numbers</strong> (when not quoted):
    +		<span class="editorNum">41.79</span>,
    +		<span class="editorSign">-</span><span class="editorNum">88.1</span>,
    +	<li><strong>Booleans</strong> (when not quoted):
     		<span class="editorBool">true</span>,
     		<span class="editorBool">false</span>.
    +<!--
     		Note that in shell files these values are usually quoted,
     		which means they are strings, not booleans.
     		The shell has no boolean type, unlike JSON and many newer programming languages which do.
    +-->
     	</ul>
     <li>Special characters: <span class="editorSpecial">{ } : , =</span>
     <li>JSON brackets: <span class="editorBracketsJSON">[ ]</span>
    +<!--
     <li>Shell comments: <span class="editorShellComment"># This is a shell comment</span>
     <li>Many reserved words and common commands in shell scripts
     	like "if" and "sudo" have special colors.
    +-->
     </ul>
     
    -<h3>Debugging</h3>
    -If you see different colors while editing a file, check for syntax errors:
    +<h2>Debugging</h2>
    +If you see different colors than above while editing a file, check for syntax errors:
     <ul>
     <li>JSON files
     	<ul>
     	<li>Missing commas are needed after each value except the last one in a sub-section.
     		This is the most common syntax error.
    +		<br>
    +		<span class="editorSpecial">{</span>
    +		<br>
    +		<span class="editorSpecial">
    +		<span class="editorSetting">"setting1"</span>
    +		:
    +		<span class="editorString">"value 1"</span>
    +		</span>
    +			<span style="color: red">&nbsp; &nbsp; &lt; missing comma</span>
    +		<br>
    +		<span class="editorSpecial">
    +		<span class="editorSetting">"setting2</span>
    +		:
    +		<span class="editorString">"value 2"</span>
    +		</span>
    +			<span style="color: red">&nbsp; &nbsp; &lt; last entry, no comma needed</span>
    +		<br>
    +		<span class="editorSpecial">}</span>
    +
     	<li>Missing quotes - setting names and string values must be surrounded by double quotes.
    +		<br>
    +		<span class="editorSpecial">
    +		<span class="editorSetting">setting1</span>:<span class="editorString">"value 1"</span>
    +		</span>
    +			<span style="color: red">&nbsp; &nbsp; &lt; missing quotes around name</span>
     	<li>Missing colons - one must separate each setting name from its value.
     		There can be 0 or more spaces before and/or after the colon.
    +		<span class="editorSpecial">
    +		<span class="editorSetting">setting1</span>
    +		<span class="editorString">"value 1"</span>
    +		</span>
    +			<span style="color: red">&nbsp; &nbsp; &lt; missing colon</span>
     	</ul>
    +<!--
     <li>Shell files
     	<ul>
     	<li>Missing quotes - strings that contain a space, tab, or special character
    @@ -119,6 +159,7 @@ <h3>Debugging</h3>
     		and the <span class="editorSpecial">=</span>
     		and between the and the <span class="editorSpecial">=</span> and the value.
     	</ul>
    +-->
     </ul>
     </p>
     
    diff --git a/html/documentation/settings/EditorPageNotEnabled.png b/html/documentation/settings/EditorPageNotEnabled.png
    new file mode 100644
    index 000000000..166b8d951
    Binary files /dev/null and b/html/documentation/settings/EditorPageNotEnabled.png differ
    diff --git a/html/documentation/settings/allsky.html b/html/documentation/settings/allsky.html
    index 9014f8ab0..60166c072 100644
    --- a/html/documentation/settings/allsky.html
    +++ b/html/documentation/settings/allsky.html
    @@ -15,6 +15,7 @@
     		} 
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
    +	<link href="../css/custom.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
     	<script src="../js/all.min.js" type="application/javascript"></script>
     	<title>AllSky Settings</title>
    @@ -24,42 +25,28 @@
     <div class="Layout">
     <div class="Layout-sidebar" w3-include-html="/documentation/sidebar.html" id="sidebar"></div>
     <div class="Layout-main markdown-body" id="mainContents">
    -
    -Once you've installed Allsky there are many settings you must modify.
    -These settings <b>must</b> be changed via the WebUI rather than manually editing files
    -since the WebUI performs error checking and updates other files as appropriate.
    -<p>
    -The WebUI contains many pages; the two used to changes settings are
    -the <span class="WebUIWebPage">Allsky Settings</span> and
    -<span class="WebUIWebPage">Editor</span> pages,
    -and are described below.
    -
    -<h2><i class="fa fa-camera fa-fw"></i> <span class="WebUIWebPage">Allsky Settings</span> WebUI Page</h2>
    -<details><summary></summary>
     <p>
    -This section lists the settings that are updated in the WebUI's
    +After you install Allsky for the first time
    +there are many settings you must modify via the WebUI's
     <span class="WebUIWebPage">Allsky Settings</span> page.
    -A (partial) typical page is below.
    -</p>
    -<img allsky="true" src="AllskySettingsPage.png" class="imgBorder imgCenter" title="Typical Allsky Settings page" alt="AllskySettings" loading="lazy">
    -
    -<p>
    -<blockquote>
    -<b>RPi camera users, note</b>
    -that several settings have different ranges on Buster versus Bullseye,
    -so you'll need to update them when upgrading to Bullseye.
    -For example, <span class="WebUISetting">Contrast</span> in Buster ranged from
    --100 to 100, whereas in Bullseye it starts at 0.
    -</blockquote>
    +If you are upgrading from a recent prior release those settings can optionally be
    +brought to the new release.
    +Settings that change the look and feel of an Allsky Website are described
    +<a external="true" href="allskyWebsite.html">here</a>.
     </p>
     <p>
    -The exact list of settings available depends on your camera model;
    +The list of settings available in the WebUI depends on your camera model;
     settings specific to a camera <b>type</b> (e.g., RPi or ZWO)
     are indicated as such in the table below.
     Where appropriate, the WebUI displays the minimum, maximum, and default values when
     you hover over a value,
     and only displays settings the camera supports, like cooler temperature for cooled cameras.
     </p>
    +<p>
    +A (partial) typical page is below.
    +</p>
    +<img allsky="true" src="AllskySettingsPage.png" class="imgBorder imgCenter" title="Typical Allsky Settings page" alt="AllskySettings" loading="lazy">
    +
     <div class="legend">
     <span class="legendHeader">Legend:</span>
     <ul class="minimalPadding">
    @@ -84,24 +71,24 @@ <h2><i class="fa fa-camera fa-fw"></i> <span class="WebUIWebPage">Allsky Setting
     </tr>
     </thead>
     <tbody>
    -<tr><td class="note" colspan="3">Daytime settings</td></tr>
    +<tr><td id="daytimesettings" class="settingsHeader" colspan="3">Daytime Settings</td></tr>
     
    -<tr><td><span class="WebUISetting">Daytime Capture</span></td>
    +<tr><td id="takedaytimeimages"><span class="WebUISetting">Daytime Capture</span></td>
     	<td>Yes</td>
     	<td>Enable to <b>capture</b> images during the day.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Daytime Save</span> <span class="AW">AW</span></td>
    +<tr><td id="savedaytimeimages"><span class="WebUISetting">Daytime Save</span> <span class="AW">AW</span></td>
     	<td>No</td>
     	<td>Enable to <b>save</b> images during the day (they are always saved at night).
     	Only applies if <span class="WebUISetting">Daytime Capture</span> is enabled.
     	</td>
     </tr>
    -<tr><td><span class="WebUISetting">Auto-Exposure</span></td>
    +<tr><td id="dayautoexposure"><span class="WebUISetting">Auto-Exposure</span></td>
     	<td>Yes</td>
     	<td>Turns on/off Auto-Exposure, which delivers properly exposed images even if
     	the overall brightness of the sky changes due to cloud cover, sun, etc.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Max Auto-Exposure</span></td>
    +<tr><td id="daymaxautoexposure"><span class="WebUISetting">Max Auto-Exposure</span></td>
     	<td><span class="cameraDependent">CD</span></td>
     	<td>The maximum exposure in milliseconds when using 
     	<span class="WebUISetting">Auto-Exposure</span>.
    @@ -109,13 +96,13 @@ <h2><i class="fa fa-camera fa-fw"></i> <span class="WebUIWebPage">Allsky Setting
     	this value will be used as the delay between frames.
     	Ignored if <span class="WebUISetting">Auto-Exposure</span> is off.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Manual Exposure</span></td>
    +<tr><td id="dayexposure"><span class="WebUISetting">Manual Exposure</span></td>
     	<td>0.5</td>
     	<td>Manual exposure time in milliseconds.
     	If <span class="WebUISetting">Auto-Exposure</span>
     	is on this value is used as a starting exposure.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Mean Target</span></td>
    +<tr><td id="daymean"><span class="WebUISetting">Mean Target</span></td>
     	<td>0.5</td>
     	<td>The target mean brightness level when
     	<span class="WebUISetting">Auto-Exposure</span> is on.
    @@ -123,7 +110,7 @@ <h2><i class="fa fa-camera fa-fw"></i> <span class="WebUIWebPage">Allsky Setting
     	Best used when both <span class="WebUISetting">Auto-Exposure</span> and
     	<span class="WebUISetting">Auto-Gain</span> are enabled.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Mean Threshold</span></td>
    +<tr><td id="daymeanthreshold"><span class="WebUISetting">Mean Threshold</span></td>
     	<td>0.1</td>
     	<td>When using <span class="WebUISetting">Mean Target</span>,
     		this specifies how close (plus or minus) the target brightness should be to the
    @@ -134,19 +121,11 @@ <h2><i class="fa fa-camera fa-fw"></i> <span class="WebUIWebPage">Allsky Setting
     		then the target brightness ranges from <code>0.3</code> to <code>0.5</code>
     	</td>
     </tr>
    -<tr><td><span class="WebUISetting">Brightness</span></td>
    -	<td><span class="cameraDependent">CD</span></td>
    -	<td>This setting changes the amount of light in images.
    -		<br>This settings has been <strong>deprecated</strong> and will be
    -		removed in a future release.
    -		Use <span class="WebUISetting">Mean Target</span> to adjust the brightness instead.
    -	</td>
    -</tr>
    -<tr><td><span class="WebUISetting">Delay</span></td>
    +<tr><td id="daydelay"><span class="WebUISetting">Delay</span></td>
     	<td>5000</td>
     	<td>Time in milliseconds to wait between the end of one image and the start of the next.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Auto-Gain</span></td>
    +<tr><td id="dayautogain"><span class="WebUISetting">Auto-Gain</span></td>
     	<td>No</td>
     	<td>Turns on/off Auto-Gain which delivers properly exposed images even if
     	the overall brightness of the sky changes.
    @@ -154,12 +133,12 @@ <h2><i class="fa fa-camera fa-fw"></i> <span class="WebUIWebPage">Allsky Setting
     	With ZWO cameras you'll probably want this off and use the lowest
     	gain possible since daytime images are bright and don't need any gain.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Max Auto-Gain</span></td>
    +<tr><td id="daymaxautogain"><span class="WebUISetting">Max Auto-Gain</span></td>
     	<td><span class="cameraDependent">CD</span></td>
     	<td>Maximum gain when using <span class="WebUISetting">Auto-Gain</span>.
     	Ignored if <span class="WebUISetting">Auto-Gain</span> is off.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Gain</span></td>
    +<tr><td id="daygain"><span class="WebUISetting">Gain</span></td>
     	<td><span class="cameraDependent">CD</span></td>
     	<td>Gain is similar to ISO on regular cameras.
     	When <span class="WebUISetting">Auto-Gain</span> is on, this value is used as a starting gain.
    @@ -169,7 +148,27 @@ <h2><i class="fa fa-camera fa-fw"></i> <span class="WebUIWebPage">Allsky Setting
     	daytime images are normally bright and don't need any additional gain.
     	</td>
     </tr>
    -<tr><td><span class="WebUISetting">Binning</span></td>
    +<tr><td id="dayimagestretchamount"><span class="WebUISetting">Stretch Amount</span></td>
    +	<td>0</td>
    +	<td>Stretching increases the contrast of an image without saturating the bright parts
    +		or making the dark parts turn black.
    +		It is most often used to lighten an image so isn't normally used during the day.
    +		This setting determines how much to change the image:
    +		3 is typical and 20 is a lot.
    +		<br>Set to <span class="WebUIValue">0</span> to disable stretching.
    +	<br>
    +	See <a allsky="true" external="true" href="../explanations/exposureGainSaturation.html#stretch">this page</a>
    +	for more information on stretching images.
    +	</td>
    +</tr>
    +<tr><td id="dayimagestretchmidpoint"><span class="WebUISetting">Stretch Mid Point</span></td>
    +	<td>10</td>
    +	<td>Determines what brightness level in the image should be stretched:
    +	0 stretches black pixels, 50 stretches middle-gray, etc.
    +	</td>
    +</tr>
    +
    +<tr><td id="daybin"><span class="WebUISetting">Binning</span></td>
     	<td>1x1</td>
     	<td>Bin 2x2 collects the light from 4 pixels to form one larger pixel on the image.
     	Bin 3x3 uses 9 pixels, etc.
    @@ -182,142 +181,150 @@ <h2><i class="fa fa-camera fa-fw"></i> <span class="WebUIWebPage">Allsky Setting
     	</blockquote>
     	</td>
     </tr>
    -<tr><td><span class="WebUISetting">Auto White Balance</span></td>
    +<tr><td><a id="dayawb"><span class="WebUISetting">Auto White Balance</span></td>
     	<td>No</td>
     	<td>Sets daytime auto white balance.
     	When used, <span class="WebUISetting">Red balance</span> and
     	<span class="WebUISetting">Blue balance</span> are used as starting points.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Red Balance</span></td>
    +<tr><td id="daywbr"><span class="WebUISetting">Red Balance</span></td>
     	<td><span class="cameraDependent">CD</span></td>
     	<td>The intensity of the red component of the image.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Blue Balance</span></td>
    +<tr><td id="daywbb"><span class="WebUISetting">Blue Balance</span></td>
     	<td><span class="cameraDependent">CD</span></td>
     	<td>The intensity of the blue component of the image.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Frames To Skip</span></td>
    +<tr><td id="dayskipframes"><span class="WebUISetting">Frames To Skip</span></td>
     	<td>5</td>
     	<td>When starting Allsky during the day, skip <i>up to</i> this many images
     	while the auto-exposure software gets to the correct exposure.
     	<br>Only applies if daytime <span class="WebUISetting">Auto-Exposure</span> is enabled.
     	</td>
     </tr>
    -<tr><td><span class="WebUISetting">Cooling</span></td>
    +<tr><td id="dayenablecooler"><span class="WebUISetting">Cooling</span></td>
     	<td>No</td>
     	<td>(ZWO cooled cameras only) Enable to use cooling on cameras that support it.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Target Temp.</span></td>
    +<tr><td id="daytargettemp"><span class="WebUISetting">Target Temp.</span></td>
     	<td>0</td>
     	<td>(ZWO cooled cameras only) Sensor's target temperature <i>when cooler is enabled</i>.
     	In degrees Celsius.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Tuning File</span></td>
    +<tr><td><a id="daytuningfile"><span class="WebUISetting">Tuning File</span></td>
     	<td>No</td>
    -	<td>(RPi on Bullseye only) Name of the optional daytime tuning file.
    -	See <a href="https://www.raspberrypi.com/documentation/accessories/camera.html"
    -		target="_blank">this documentation</a> for more information.
    +	<td>(RPi on Bookworm and Bullseye only) Full path name of the optional daytime tuning file.
    +	See the
    +	<a external="true" href="https://www.raspberrypi.com/documentation/computers/camera_software.html#tuning-files">
    +	Raspberry Pi Documentation</a> for more information.
    +	<blockquote>
    +	System-supplied tuning files on Raspberry Pi models prior to 5 are in
    +	<span class="fileName">/usr/share/libcamera/ipa/rpi/vc4</span>.
    +	New Pi's files are in
    +	<span class="fileName">/usr/share/libcamera/ipa/rpi/pisp</span>.
    +	</blockquote>
     	</td>
     </tr>
     
    -<tr><td class="note" colspan="3">Nighttime settings
    -	<p>Unless otherwise specified, these setttings are the same as the daytime ones.</td>
    +<tr><td id="nighttimesettings" class="settingsHeader" colspan="3">Nighttime Settings</td></tr>
    +<tr><td class="settingsHeader settingsHeaderNote" colspan="3">
    +	Unless otherwise specified, these setttings are the same as the daytime ones.</td>
     </tr>
     
    -<tr><td><span class="WebUISetting">Auto-Exposure</span></td>
    +<tr><td id="nightautoexposure"><span class="WebUISetting">Auto-Exposure</span></td>
     	<td>Yes</td>
     	<td></td>
     </tr>
    -<tr><td><span class="WebUISetting">Max Auto-Exposure</span></td>
    +<tr><td id="nightmaxautoexposure"><span class="WebUISetting">Max Auto-Exposure</span></td>
     	<td><span class="cameraDependent">CD</span></td>
     	<td></td>
     </tr>
    -<tr><td><span class="WebUISetting">Manual Exposure</span></td>
    +<tr><td id="nightexposure"><span class="WebUISetting">Manual Exposure</span></td>
     	<td>10000</td>
     	<td></td>
     </tr>
    -<tr><td><span class="WebUISetting">Mean Target</span></td>
    +<tr><td id="nightmean"><span class="WebUISetting">Mean Target</span></td>
     	<td>0.2</td>
     	<td></td>
     </tr>
    -<tr><td><span class="WebUISetting">Mean Threshold</span></td>
    +<tr><td id="nightmeanthreshold"><span class="WebUISetting">Mean Threshold</span></td>
     	<td>0.1</td>
     	<td></td>
     </tr>
    -<tr><td><span class="WebUISetting">Brightness</span></td>
    -	<td><span class="cameraDependent">CD</span></td>
    -	<td>This settings has been <strong>deprecated</strong> and will be
    -		removed in a future release.
    -		Use <span class="WebUISetting">Mean Target</span> to adjust the brightness instead.
    -	</td>
    -</tr>
    -<tr><td><span class="WebUISetting">Delay</span></td>
    +<tr><td id="nightdelay"><span class="WebUISetting">Delay</span></td>
     	<td>10</td>
     	<td></td>
     </tr>
    -<tr><td><span class="WebUISetting">Auto-Gain</span></td>
    +<tr><td id="nightautogain"><span class="WebUISetting">Auto-Gain</span></td>
     	<td>No</td>
     	<td>With ZWO cameras enabling <span class="WebUISetting">Auto-Exposure</span> and
     	<span class="WebUISetting">Auto-Gain</span> together
     	can produce unpredictable results so testing is needed.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Max Auto-Gain</span></td>
    +<tr><td id="nightmaxautogain"><span class="WebUISetting">Max Auto-Gain</span></td>
     	<td><span class="cameraDependent">CD</span></td>
     	<td></td>
     </tr>
    -<tr><td><span class="WebUISetting">Gain</span></td>
    +<tr><td id="nightgain"><span class="WebUISetting">Gain</span></td>
     	<td><span class="cameraDependent">CD</span></td>
     	<td>The default is one-half the maximum for the camera.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Binning</span></td>
    +<tr><td id="nightimagestretchamount"><span class="WebUISetting">Stretch Amount</span></td>
    +	<td>0</td>
    +	<td></td>
    +	</td>
    +</tr>
    +<tr><td id="nightimagestretchmidpoint"><span class="WebUISetting">Stretch Mid Point</span></td>
    +	<td>10</td>
    +	<td></td>
    +</tr>
    +<tr><td id="nightbin"><span class="WebUISetting">Binning</span></td>
     	<td>1x1</td>
     	<td></td>
     </tr>
    -<tr><td><span class="WebUISetting">Auto White Balance</span></td>
    +<tr><td id="nightawb"><span class="WebUISetting">Auto White Balance</span></td>
     	<td>No</td>
     	<td></td>
     </tr>
    -<tr><td><span class="WebUISetting">Red Balance</span></td>
    +<tr><td id="nightwbr"><span class="WebUISetting">Red Balance</span></td>
     	<td><span class="cameraDependent">CD</span></td>
     	<td></td>
     </tr>
    -<tr><td><span class="WebUISetting">Blue Balance</span></td>
    +<tr><td id="nightwbb"><span class="WebUISetting">Blue Balance</span></td>
     	<td><span class="cameraDependent">CD</span></td>
     	<td></td>
     </tr>
    -<tr><td><span class="WebUISetting">Frames To Skip</span></td>
    +<tr><td id="nightskipframes"><span class="WebUISetting">Frames To Skip</span></td>
     	<td>1</td>
     	<td>Only applies if nighttime <span class="WebUISetting">Auto-Exposure</span> is enabled.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Cooling</span></td>
    +<tr><td id="nightenablecooler"><span class="WebUISetting">Cooling</span></td>
     	<td>No</td>
     	<td>(ZWO cooled cameras only)</td>
     </tr>
    -<tr><td><span class="WebUISetting">Target Temp.</span></td>
    +<tr><td id="nighttargettemp"><span class="WebUISetting">Target Temp.</span></td>
     	<td>0</td>
     	<td>(ZWO cooled cameras only)</td>
     </tr>
    -<tr><td><span class="WebUISetting">Tuning File</span></td>
    +<tr><td><a id="nighttuningfile"><span class="WebUISetting">Tuning File</span></td>
     	<td>No</td>
    -	<td>(RPi on Bullseye only)</td>
    +	<td>(RPi on Bookworm and Bullseye only)</td>
     </tr>
     
    -<tr><td class="note" colspan="3">Both daytime and nighttime settings</td></tr>
    -
    -<!-- THIS MAY CHANGE IN THE FUTURE. THE RPi width AND height BEHAVIOR HAS ONLY BEEN TESTED WITH LIBCAMERA.
    -a "digital zoom" whereby the specified portion of the camera's sensor is used when taking a picture,
    -but the picture is then expanded so <b>the final picture resolution is the same as the camera's resolution</b>.
    -0 turns off digital zoom and uses the sensor's maximum width.
    -Look up your camera specifications to know what values are supported.
    -If you want the final image to contain only a portion of what strikes the sensor,
    -use the <span class="shSetting">CROP</span> setting in the
    -<span class="fileName">config.sh</span> file instead.
    --->
    -<tr><td><span class="WebUISetting">Configuration File</span></td>
    +<tr><td class="settingsHeader" colspan="3">Both Day and Night Settings</td></tr>
    +<tr><td id="daystokeep"><span class="WebUISetting">Days To Keep</span></td>
    +	<td>14</td>
    +	<td>Number of days of images and videos in
    +	<span class="fileName">~/allsky/images</span> to keep.
    +	Any directory older than this many days will be deleted at each end of night.
    +	Set to <span class="WebUIValue">0</span> to keep ALL days' data;
    +	you will need to manually manage disk space.</td>
    +</tr>
    +<tr><td id="config"><span class="WebUISetting">Configuration File</span></td>
     	<td>[none]</td>
     	<td>Configuration file to use for settings.  Not currently used.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Extra Parameters</span></td>
    +<tr><td id="extraargs"><span class="WebUISetting">Extra Parameters</span></td>
     	<td></td>
     	<td>(RPi only) Any additional parameters to send to the
     		<code>libcamera-still</code> image capture program.
    @@ -326,35 +333,26 @@ <h2><i class="fa fa-camera fa-fw"></i> <span class="WebUIWebPage">Allsky Setting
     		<br>
     	</td>
     </tr>
    -<tr><td><span class="WebUISetting">Saturation</span></td>
    +<tr><td id="saturation"><span class="WebUISetting">Saturation</span></td>
     	<td><span class="cameraDependent">CD</span></td>
     	<td>(RPi only) Sets saturation from black and white to extra saturated.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Contrast</span></td>
    +<tr><td id="contrast"><span class="WebUISetting">Contrast</span></td>
     	<td><span class="cameraDependent">CD</span></td>
     	<td>(RPi only) Changes the difference between blacks and whites in an image.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Sharpness</span></td>
    +<tr><td id="sharpness"><span class="WebUISetting">Sharpness</span></td>
     	<td><span class="cameraDependent">CD</span></td>
     	<td>(RPi only) Changes the sharpness of an imgage.
     	Images that are too sharp look unnatural.</td>
     </tr>
    -<tr><td><span class="WebUISetting class="WebUISetting"">Gamma</span></td>
    -	<td><span class="cameraDependent">CD</span></td>
    -	<td>(ZWO only) Increases or decreases contrast between dark and bright areas.</td>
    -</tr>
    -<tr><td><span class="WebUISetting">Offset</span></td>
    -	<td>0</td>
    -	<td>(ZWO only) Adds about 1/10 the specified value to every pixel,
    -	which brightens the whole image.</td>
    -</tr>
    -<tr><td><span class="WebUISetting">Aggression</span></td>
    +<tr><td id="aggression"><span class="WebUISetting">Aggression</span></td>
     	<td>75%</td>
     	<td>(ZWO only) Specifies how much of a calculated exposure change should be made
     	during auto-exposure. Lower numbers smooth out brightness changes but take longer to
     	react to changes.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Gain Transition Time</span></td>
    +<tr><td id="gaintransitiontime"><span class="WebUISetting">Gain Transition Time</span></td>
     	<td>15</td>
     	<td>(ZWO only) Number of <b>minutes</b> over which to increase or decrease
     	the gain when going from day-to-night or night-to-day images.
    @@ -362,33 +360,33 @@ <h2><i class="fa fa-camera fa-fw"></i> <span class="WebUIWebPage">Allsky Setting
     	Only works if nighttime <span class="WebUISetting">Auto-Gain</span> is off.
     	<span class="WebUIValue">0</span> disables transitions.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Image Width</span></td>
    +<!-- NO LONGER USED
    +<tr><td id="width"><span class="WebUISetting">Image Width</span></td>
     	<td>0</td>
    -	<td><span class="WebUIValue">0</span> uses the sensor's full width in pixels.
    -	Otherwise, with <b>ZWO</b> cameras the
    -	<span class="WebUISetting">Image Width</span> and <span class="WebUISetting">Image Height</span>
    -	settings set a crop area around the center of the sensor,
    -	the same as the <span class="shSetting">CROP</span> setting in
    -	<span class="fileName">config.sh</span>.
    -	With <b>RPi</b> cameras these settings decrease resolution of the full-sensor image,
    -	then increase the resolution, thereby negating the changes.
    -	<br><b>There is no reason to set the <span class="WebUISetting">Width</span> and
    -	<span class="WebUISetting">Height</span> with RPi cameras.</b></td>
    -</tr>
    -<tr><td><span class="WebUISetting">Image Height</span></td>
    +	<td>(ZWO only)
    +	This and the <span class="WebUISetting">Image Height</span>
    +	crop the image around the center of the sensor while the picture is being taken,
    +	resulting in a smaller-sized file being written to tisk.
    +	<span class="WebUIValue">0</span> uses the sensor's full width in pixels.
    +	</td>
    +</tr>
    +<tr><td id="height"><span class="WebUISetting">Image Height</span></td>
     	<td>0</td>
    -	<td> Same as </span class="WebUISetting">Width</span></span> but for the sensor's height.</td>
    +	<td>(ZWO only)
    +		Same as <span class="WebUISetting">Width</span> but for the sensor's height.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Image Type</span></td>
    +-->
    +<tr><td id="type"><span class="WebUISetting">Image Type</span></td>
     	<td>auto</td>
     	<td>Image format: <b>auto</b>:
     	automatically picks the best type of image based on the camera.
    -	If you have a color camera it will use RGB24; mono cameras use RAW16 if the output file is a .png,
    +	If you have a color camera it will use RGB24;
    +	mono cameras use RAW16 if the output file is a .png,
     	otherwise RAW8 is used.
     	<b>RAW8</b>: 8-bit mono. <b>RGB24</b>: color (red, green, blue), 8 bits per channel.
     	<b>RAW16</b>: 16-bit mono.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Quality</span></td>
    +<tr><td id="lity"><span class="WebUISetting">Quality / Compression</span></td>
     	<td>95</td>
     	<td>For JPG images, this specifies the quality - 0 (low quality) to 100 (high quality).
     	Larger numbers produce higher-quality, but larger, files.
    @@ -397,33 +395,33 @@ <h2><i class="fa fa-camera fa-fw"></i> <span class="WebUIWebPage">Allsky Setting
     	If you use very short delays between pictures you may want to play with these
     	numbers to get the quickest delay possible.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Auto USB Bandwidth</span></td>
    +<tr><td id="autousb"><span class="WebUISetting">Auto USB Bandwidth</span></td>
     	<td>Yes</td>
     	<td>(ZWO only) Automatically sets the <span class="WebUISetting">USB bandwidth</span>.</td>
     </tr>
    -<tr><td><span class="WebUISetting">USB Bandwidth</span></td>
    +<tr><td id="usb"><span class="WebUISetting">USB Bandwidth</span></td>
     	<td><span class="cameraDependent">CD</span></td>
     	<td>(ZWO only) How much of the USB bandwidth to use.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Filename</span> <span class="AW">AW</span></td>
    +<tr><td id="filename"><span class="WebUISetting">Filename</span> <span class="AW">AW</span></td>
     	<td>image.jpg</td>
     	<td>The name of the image file. Supported extensions are <b>jpg</b> and <b>png</b>.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Rotation</span></td>
    +<tr><td id="rotation"><span class="WebUISetting">Rotation</span></td>
     	<td>None</td>
    -	<td>(RPi on Bullseye only) How to rotate the image.
    -	On <b>Bullseye</b> images can only be rotated 180 degrees.</td>
    +	<td>(RPi on Bookworm and Bullseye only) How to rotate the image.
    +	Choices are 0 and 180 degrees.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Flip</span></td>
    +<tr><td id="flip"><span class="WebUISetting">Flip</span></td>
     	<td>No flip</td>
     	<td>How to flip the image (No flip, Horizontal, Vertical, or Both).</td>
     </tr>
    -<tr><td><span class="WebUISetting">Notification Images</span></td>
    -	<td>Yes</td>
    -	<td>Displays notification images, e.g., "Camera off during day" if
    -	daytime images are not being taken.</td>
    +	
    +<tr><td id="determinefocus"><span class="WebUISetting">Record Focus</span></td>
    +	<td>No</td>
    +	<td>Enable to have the overall focus of the image determined and output via the 'FOCUS' variable, which can then be used in an overlay.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Consistent Delays Between Images</span></td>
    +<tr><td id="consistentdelays"><span class="WebUISetting">Consistent Delays Between Images</span></td>
     	<td>Yes</td>
     	<td>Enable this to force the time between the start of exposures to be a consistent length
     	(<span class="WebUISetting">Max Auto-Exposure + Delay</span>).
    @@ -431,21 +429,45 @@ <h2><i class="fa fa-camera fa-fw"></i> <span class="WebUIWebPage">Allsky Setting
     	for example, every 90 seconds, regardless of how long an individual frame's exposure is.
     	</td>
     </tr>
    -<tr><td><span class="WebUISetting">Latitude</span> <span class="AW">AW</span></td>
    +<tr><td id="timeformat"><span class="WebUISetting">Time Format</span></td>
    +	<td>%Y%m%d %H:%M:%S</td>
    +	<td>Determines the format of the displayed time.
    +	Run <code>man 3 strftime</code> to see the options.</td>
    +</tr>
    +<tr><td id="temptype"><span class="WebUISetting">Temperature Units</span></td>
    +	<td>Celsius</td>
    +	<td>Determines what unit(s) the temperature will be displayed in (Celsius, Fahrenheit, or Both).</td>
    +</tr>
    +<tr><td id="latitude"><span class="WebUISetting">Latitude</span> <span class="AW">AW</span></td>
     	<td></td>
     	<td>Latitude of the camera.
    -	Formats include: 123.4N, 123.4S, 123.4, or -123.4.
    +	Formats include: 123.4N, 123.4S, +123.4, or -123.4.
     	Southern hemisphere is negative.
    +	The "+" is needed for positive numbers.
     	</td>
     </tr>
    -<tr><td><span class="WebUISetting">Longitude</span> <span class="AW">AW</span></td>
    +<tr><td id="longitude"><span class="WebUISetting">Longitude</span> <span class="AW">AW</span></td>
     	<td></td>
     	<td>Longitude of the camera.
    -	Formats include: 123.4E, 123.4W, 123.4, or -123.4.
    +	Formats include: 123.4E, 123.4W, +123.4, or -123.4.
     	West is negative.
    +	The "+" is needed for positive numbers.
    +	<blockquote>
    +	The actual values for latitude and longitude are not displayed on the map,
    +	but users can zoom in to see exactly where your camera is.
    +	If that bothers you, change the latitude and/or longitude values slightly.
    +	Allsky itself only uses them to determine when daytime and nighttime begin so
    +	you can change the values a fair amount and not impact anything.
    +	<br><br>
    +	When installing Allsky the first time,
    +	the installation script will attempt to determine your rough
    +	<span class="WebUISetting">Latitude</span> and
    +	<span class="WebUISetting">Longitude</span> based on your IP address.
    +	These values may be "close enough" in most cases but you may want to check them anyhow.
    +	</blockquote>
     	</td>
     </tr>
    -<tr><td><span class="WebUISetting">Angle</span> <span class="AW">AW</span></td>
    +<tr><td id="angle"><span class="WebUISetting">Angle</span> <span class="AW">AW</span></td>
     	<td>-6</td>
     	<td>Altitude of the Sun above or below the horizon at which daytime and nighttime switch.
     	Can be negative (Sun below horizon) or positive (Sun above horizon).
    @@ -459,23 +481,23 @@ <h2><i class="fa fa-camera fa-fw"></i> <span class="WebUIWebPage">Allsky Setting
     	for a more detailed description of <span class="WebUISetting">Angle</span>.
     	</td>
     </tr>
    -<tr><td><span class="WebUISetting">Take Dark Frames</span></td>
    +<tr><td id="takedarkframes"><span class="WebUISetting">Take Dark Frames</span></td>
     	<td>No</td>
     	<td>Enable to take dark frames which are use to decrease noise in images.
     	<br>
     	See the
    -	<a allsky="true" href="/documentation/explanations/darkFrames.html">in-depth explanation</a>
    +	<a allsky="true" external="true" href="/documentation/explanations/darkFrames.html">in-depth explanation</a>
     	of dark frames, including how to take and use them.
     	</td>
     </tr>
    -<tr><td><span class="WebUISetting">Use Dark Frames</span></td>
    +<tr><td id="usedarkframes"><span class="WebUISetting">Use Dark Frames</span></td>
     	<td>No</td>
     	<td>Enable to perform dark frame subtraction at night.
     	<br>Requires that you first took dark frames using the
     	<span class="WebUISetting">Take Dark Frames</span> setting.
     	</td>
     </tr>
    -<tr><td><span class="WebUISetting">Locale</span></td>
    +<tr><td id="locale"><span class="WebUISetting">Locale</span></td>
     	<td></td>
     	<td>The locale is used to determine what the thousands and decimal separators are
     	as well as the language to use for many non-Allsky commands.
    @@ -491,7 +513,7 @@ <h2><i class="fa fa-camera fa-fw"></i> <span class="WebUIWebPage">Allsky Setting
     	before continuing the installation.
     	</blockquote>
     	</p>
    -	If the correct local isn't in the list it needs to be installed,
    +	If the correct locale isn't in the list it needs to be installed,
     	so <code>&lt;Cancel&gt;</code> out of the installation and run
     	<code>sudo raspi-config</code>, selecting <code>Localisation Options</code>,
     	followed by the <code>Locale</code> option.
    @@ -502,17 +524,29 @@ <h2><i class="fa fa-camera fa-fw"></i> <span class="WebUIWebPage">Allsky Setting
     	when done, re-run the installation script selecting the locale you just installed.
     	</td>
     </tr>
    -<tr><td><span class="WebUISetting">New Exposure Algorithm</span></td>
    -	<td>No</td>
    -	<td>(ZWO only) Activate to use a auto-exposure algorithm at night.
    -		Initial testing indictes the images taken during the day-to-night transition
    -		as well as at night have better exposures.
    -		If you use this, please add a Discussion item describing your results - good or bad.
    -		We need the feedback.
    -		<br>If this provides better results overall it will be the default in the next release.
    -	</td>
    -</tr>
    -<tr><td><span class="WebUISetting">Histogram Box</span></td>
    +<tr><td id="zwoexposuretype"><span class="WebUISetting">ZWO Exposure Type</span></td>
    +	<td>Snapshot</td>
    +	<td>(ZWO only) Determines what type of exposures to take:
    +	<ol class="minimalPadding topPadding">
    +		<li><span class="WebUIValue">Snapshot</span>
    +			takes pictures one at a time like a "normal" camera.
    +			Note that <a href="#dayautogain"><span class="WebUISetting">Auto-Gain</span></a>
    +			and <a href="#dayawb"><span class="WebUISetting">Auto White Balance</span></a>
    +			does not work when <span class="WebUIValue">Snapshot</span> is used.
    +		</li>
    +		<li><span class="WebUIValue">Video Off</span>
    +			takes pictures like a video camera that is turned off after every picture.
    +			This is the same as the old
    +			<span class="WebUISetting">Version 0.8 exposure</span> setting.
    +		</li>
    +		<li><span class="WebUIValue">Video (original)</span>
    +			takes pictures like a video camera that is always on.
    +			This is the original exposure method.
    +		</li>
    +	</ol>
    +	The first two types decrease the sensor temperature between 5 - 15 degrees Celsius.
    +</tr>
    +<tr><td id="histogrambox"><span class="WebUISetting">Histogram Box</span></td>
     	<td>500 500 50 50</td>
     	<td>(ZWO only) X and Y size of histogram box in pixels and the middle point of
     	the box in percent.
    @@ -521,48 +555,37 @@ <h2><i class="fa fa-camera fa-fw"></i> <span class="WebUIWebPage">Allsky Setting
     	If the Sun goes through the center of your image you may want to move the box.
     	</td>
     </tr>
    -<tr><td><span class="WebUISetting">Debug Level</span></td>
    -	<td>0</td>
    +<tr><td id="debuglevel"><span class="WebUISetting">Debug Level</span></td>
    +	<td>1</td>
     	<td>Determines the amount of output in the log file.
    +<!--
     	Log entries can also be viewed with <code>journalctl -u allsky</code>.
    +-->
     	<br><span class="WebUIValue">0</span> outputs error messages only.
     	<span class="WebUIValue">4</span> outputs a LOT of messages and generally should
     	only be used if an Allsky developers directs you to.
     	</td>
     </tr>
    -<tr><td><span class="WebUISetting">Version 0.8 Exposure</span></td>
    -	<td>Yes</td>
    -	<td>(ZWO only) Determines if the Allsky version 0.8 exposure method is used (video capture stops between images).
    -	This decreases the sensor temperature between 5 - 15 degrees Celsius.
    -	If you see ASI_ERROR_TIMEOUTs in the log file, try turning this off.
    -	See <a href="https://github.com/AllskyTeam/allsky/issues/417">Issue 417</a>.</td>
    -</tr>
    -<tr><td><span class="WebUISetting">Require WebUI Login</span></td>
    -	<td>Yes</td>
    -	<td>Determines if you need to log into the WebUI.
    -	<b>If you Pi is accessible on the Internet, do NOT disable this!!</b></td>
    -</tr>
     
    -<tr><td class="note" colspan="3">Image overlay settings
    -	<p>See the
    -	<a allsky="true" href="/documentation/overlays/overlays.html">overlay page</a>
    +<tr><td class="settingsHeader" colspan="3">Image Overlay Settings</td></tr>
    +<tr><td class="settingsHeader settingsHeaderNote" colspan="3">See the
    +	<a allsky="true" external="true" href="/documentation/overlays/overlays.html">overlay page</a>
     	for details on the image overlay.
     	<br><b>Note, the <u>image overlay</u> is what's embedded in every image;
     	the <u>constellation overlay</u> is what's optionally overlayed
     	on top of images in the Allsky Website.</b>
     	This page only deals with the <u>image overlay</u>.
    -	</p>
     </td></tr>
    -<tr><td><span class="WebUISetting">Overlay Method</span></td>
    -	<td>legacy</td>
    -	<td>Set to <span class="WebUIValue">module</span>
    -	to have image overlays added by the new, enhanced
    -	<a allsky="true" href="/documentation/overlays/overlays.html">overlay</a> program.
    -	<br><b>In this mode, the overlay settings below do NOT apply</b>.
    +<tr><td id="overlaymethod"><span class="WebUISetting">Overlay Method</span></td>
    +	<td>module</td>
    +	<td>The default now uses the new, enhanced
    +	<a allsky="true" external="true" href="/documentation/overlays/overlays.html">overlay</a>
    +	system to add overlays onto your images.
    +	<br><b>In <span class="WebUIValue">module</span> mode,
    +	the overlay settings below do NOT apply</b>.
     	<blockquote>
    -	The default will be <span class="WebUIValue">module</span> in the next major Allsky release,
    -	and in the release after that the legacy mode as well as this "Image overlay settings"
    -	section will be removed.
    +	The legacy mode and this whole "Image overlay Settings"
    +	section will be removed in the next Allsky release.
     	</blockquote>
     	<p>
     	When setting to <span class="WebUIValue">module</span>, don't forget to enable the
    @@ -571,710 +594,867 @@ <h2><i class="fa fa-camera fa-fw"></i> <span class="WebUIWebPage">Allsky Setting
     	</p>
     	</td>
     </tr>
    -<tr><td><span class="WebUISetting">Show Time</span></td>
    +<tr><td id="showtime"><span class="WebUISetting">Show Time</span></td>
     	<td>Yes</td>
     	<td>Display the time the picture was taken in the overlay?</td>
     </tr>
    -<tr><td><span class="WebUISetting">Time Format</span></td>
    -	<td>%Y%m%d %H:%M:%S</td>
    -	<td>Determines the format of the displayed time.
    -	Run <code>man 3 strftime</code> to see the options.</td>
    -</tr>
    -<tr><td><span class="WebUISetting">Show Temperature</span></td>
    +<tr><td id="showtemp"><span class="WebUISetting">Show Temperature</span></td>
     	<td>Yes</td>
     	<td>(ZWO only) Display the camera sensor temperature in the overlay?</td>
     </tr>
    -<tr><td><span class="WebUISetting">Temperature Units</span></td>
    -	<td>Celsius</td>
    -	<td>Determines what unit(s) the temperature will be displayed in (Celsius, Fahrenheit, or Both).</td>
    -</tr>
    -<tr><td><span class="WebUISetting">Show Exposure</span></td>
    +<tr><td id="showexposure"><span class="WebUISetting">Show Exposure</span></td>
     	<td>Yes</td>
     	<td>Display the exposure time in the overlay? If <span class="WebUISetting">Auto-Exposure</span> is enabled,
     	"(auto)" will appear after the exposure.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Show Gain</span></td>
    +<tr><td id="showgain"><span class="WebUISetting">Show Gain</span></td>
     	<td>Yes</td>
     	<td>Display the gain in the overlay? If <span class="WebUISetting">Auto-Gain</span> is enabled,
     	"(auto)" will appear after the gain.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Show Brightness</span></td>
    -	<td>No</td>
    -	<td>Display the brightness level in the overlay?</td>
    -</tr>
    -<tr><td><span class="WebUISetting">Show USB</span></td>
    +<tr><td id="showusb"><span class="WebUISetting">Show USB</span></td>
     	<td>No</td>
     	<td>(ZWO only) Display the USB Bandwidth in the overlay? This is primarily for debugging.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Show Mean Brightness</span></td>
    +<tr><td id="showmean"><span class="WebUISetting">Show Mean Brightness</span></td>
     	<td>No</td>
     	<td>Display the mean (average) brightness in the overlay?
     	This value is used to determine the correct auto-exposure and auto-gain levels.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Show Histogram Box</span></td>
    +<tr><td id="showhistogrambox"><span class="WebUISetting">Show Histogram Box</span></td>
     	<td>No</td>
     	<td>(ZWO only) Show the histogram box on the image?</td>
     </tr>
    -<tr><td><span class="WebUISetting">Show Focus Metric</span></td>
    +<tr><td id="showfocus"><span class="WebUISetting">Show Focus Metric</span></td>
     	<td>No</td>
     	<td>Display a focus metric in the overlay to help you focus the camera?
     		Higher numbers are better, but only use when the brightness isn't changing.
     	</td>
     </tr>
    -<tr><td><span class="WebUISetting">Text Overlay</span></td>
    +<tr><td id="text"><span class="WebUISetting">Text Overlay</span></td>
     	<td></td>
     	<td>Text overlay that appears below the time, in the same font.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Extra Text File</span></td>
    +<tr><td id="extratext"><span class="WebUISetting">Extra Text File</span></td>
     	<td></td>
     	<td>The full path name to a text file which will be displayed under other information.
     	The file can contain multiple lines which will be displayed underneath each other.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Max Age Of Extra</span></td>
    +<tr><td id="extratextage"><span class="WebUISetting">Max Age Of Extra</span></td>
     	<td>0</td>
     	<td>If you specified an <span class="WebUISetting">Extra Text File</span>
     	then it must be updated within this number of seconds;
     	if not it's contents will not be displayed.
     	Set to <span class="WebUIValue">0</span> to ignore this check and always display the contents of the file.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Line Height</span></td>
    +<tr><td id="textlineheight"><span class="WebUISetting">Line Height</span></td>
     	<td>30</td>
     	<td>The line height of the text displayed in the image.
     	If you change the font size then adjust this value if required.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Text X</span></td>
    +<tr><td id="textx"><span class="WebUISetting">Text X</span></td>
     	<td>15</td>
     	<td>Start of text from the left side, in pixels.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Text Y</span></td>
    +<tr><td id="texty"><span class="WebUISetting">Text Y</span></td>
     	<td>35</td>
     	<td>Start of text from the top, in pixels.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Font Name</span></td>
    +<tr><td id="fontname"><span class="WebUISetting">Font Name</span></td>
     	<td>Simplex</td>
     	<td>Font type for the overlay.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Font Color</span></td>
    +<tr><td id="fontcolor"><span class="WebUISetting">Font Color</span></td>
     	<td>255 0 0</td>
     	<td>Font color in Blue, Green, and Red (BGR).
     	NOTE: When using RAW 16 only the first two values are used, i.e., 255 128 0.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Small Font Color</span></td>
    +<tr><td id="smallfontcolor"><span class="WebUISetting">Small Font Color</span></td>
     	<td>0 0 255</td>
     	<td>Small font color in BGR.
     	NOTE: When using RAW 16 only the first two values are used, i.e., 255 128 0.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Font Smoothness</span></td>
    +<tr><td id="fonttype"><span class="WebUISetting">Font Smoothness</span></td>
     	<td>Antialiased</td>
     	<td>Controls the smoothness of the fonts.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Font Size</span></td>
    +<tr><td id="fontsize"><span class="WebUISetting">Font Size</span></td>
     	<td>7</td>
     	<td>Font size.
     	This is impacted by the sensor size so you'll need to experiment with this.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Font Weight</span></td>
    +<tr><td id="fontline"><span class="WebUISetting">Font Weight</span></td>
     	<td>1</td>
     	<td>Font line thickness.</td>
     </tr>
    -<tr><td><span class="WebUISetting">Use Outline Font</span></td>
    +<tr><td id="outlinefont"><span class="WebUISetting">Use Outline Font</span></td>
     	<td>No</td>
     	<td>Should an outline to the text overlay be added to improve contrast?</td>
     </tr>
     
    -<tr><td class="note" colspan="3">Allsky Map and Website Setting
    -	<p>
    -	If you want your allsky camera's location to display on the
    -	<a href='https://www.thomasjacquin.com/allsky-map'>Allsky map</a>,
    -	the information in this section will be sent to the map server every other
    -	morning to ensure it's fresh.
    -	The server automatically removes old data.
    -	Note that only a limited number of updates per day are allowed to catch bogus updates.
    -	</p>
    +<tr><td class="settingsHeader" colspan="3">Post Capture Settings</td></tr>
    +<tr><td id="imeagesremovebadlow"><span class="WebUISetting">Remove Bad Images Threshold Low</span></td>
    +	<td>0.1</td>
    +	<td>Images whose mean brightness is below this will be removed.
    +	Useful values range from <code>0.01</code> to around <code>0.2</code>.
    +	<br>Set to <code>0</code> to disable this check.
     	</td>
     </tr>
    -
    -<tr><td><span class="WebUISetting">Display Settings</span></td>
    -	<td>No</td>
    -	<td>People sometimes ask others what settings they are using.
    -	Enable this setting to add a link to your Allsky Website's popout that displays your
    -	settings in the WebUI's <span class="WebUIWebPage">Allsky Settings</span> page.
    -	<br>Only works if you are running the Allsky Website.</td></tr>
    -<tr><td><span class="WebUISetting">Show on Map</span></td>
    -	<td>No</td>
    -	<td>Enable to have your camera appear on the
    -	<a href='https://www.thomasjacquin.com/allsky-map'>Allsky map</a>.
    -	<br><b>If off, the following settings are ignored.</b></td></tr>
    -<tr><td><span class="WebUISetting">Website URL</span></td>
    -	<td></td>
    -	<td>Your website's URL, for example: https://www.thomasjacquin.com/allsky.
    -	If your camera is not accessible on the Internet or you do not want the
    -	map page to link to your website, leave this field blank.
    -	<blockquote>
    -	If a <span class="WebUISetting">Website URL</span> is specified,
    -	the <span class="WebUISetting">Image URL</span> must also be specified, and vice versa.
    -	</blockquote>
    -	</td></tr>
    -<tr><td><span class="WebUISetting">Image URL</span></td>
    -	<td></td>
    -	<td>The URL to your allsky image, for example: https://www.thomasjacquin.com/allsky/image.jpg.
    -	Right-click on the image and select <i>Copy Image Address</i> to determine what to
    -	put in this field.
    -	<br>
    -	If you have the Allsky Website installed <b>on your Pi</b> and are using its image,
    -	you may need to set this field to
    -	<span class="fileName">&lt;Pi name&gt;/current/tmp/image.jpg</span> or
    -	whatever's in the <span class="editorSetting">imageName</span> field of your website's
    -	<span class="fileName">configuration.json</span> file.
    -	Be careful of using "http" versus "https", and after enabling
    -	<span class="WebUISetting">Show On Map</span>, look at the map to ensure you can see your image.
    -	If your camera is not accessible on the Internet or you do not want the image
    -	to appear on the map page, leave this field empty.
    -	However, one of the main purposes of the map is to show pictures from cameras around the world,
    -	so adding your image URL is <b>strongly</b> encouraged.</td></tr>
    -<tr><td><span class="WebUISetting">Location</span> <span class="AW">AW</span></td>
    -	<td></td>
    -	<td>The location of your camera.
    -	You can put any level of detail you want.</td></tr>
    -<tr><td><span class="WebUISetting">Owner</span> <span class="AW">AW</span></td>
    -	<td></td>
    -	<td>The owner of the camera - your name, an association name, an observatory, etc.</td></tr>
    -<tr><td><span class="WebUISetting">Camera</span> <span class="AW">AW</span></td>
    -	<td></td>
    -	<td>The type and model of your camera, for example: <b>ZWO ASI224MC</b> or <b>RPi HQ</b>.
    -	This field is required and a default value is set during Allsky installation.</td></tr>
    -<tr><td><span class="WebUISetting">Lens</span> <span class="AW">AW</span></td>
    -	<td></td>
    -	<td>The lens you're using on your camera, for example: <b>Arecont 1.55</b>.</td></tr>
    -<tr><td><span class="WebUISetting">Computer</span> <span class="AW">AW</span></td>
    -	<td></td>
    -	<td>The computer runni g your allsky camera, for example: <b>Raspberry Pi 3</b>.
    -	This field is required and a default value is set during Allsky installation.</td></tr>
    -<tr><td><span class="WebUISetting">Latitude &amp; longitude</span> <span class="AW">AW</span></td>
    -	<td></td>
    -	<td>These are described above and are
    -	required for your camera to appear on the Allsky Map.
    -	<blockquote>
    -	The actual values are not displayed on the map,
    -	but users can zoom in to see exactly where the camera is.
    -	If that bothers you, change their values slightly.
    -	</blockquote>
    -	</td></tr>
    -
    -<tr><td class="note" colspan="3">Camera Type</td></tr>
    -
    -<tr><td><span class="WebUISetting">Camera Type</span></td>
    -	<td></td>
    -	<td>The type of camera you are using: ZWO or RPi.
    -	<br>If you have both a ZWO and RPi camera, this setting determines which to use.
    -	<br>If you replace a camera with another one of the same <strong>type</strong>
    -	(e.g., a ZWO ASI120 with a ZWO ASI290)
    -	select <span class="dropdown">Refresh</span> in the drop-down.
    -	</td></tr>
    -<!--
    -<tr><td><span class="WebUISetting">Camera Model</span></td>
    -	<td></td>
    -	<td>This read-only field displays the model of camera you are using.
    -	It is determined automatically based on what camera is connected to your Pi.
    -	<br>If more than one camera of the specified type is connected, the first one is used.</td></tr>
    --->
    -
    -</tbody>
    -</table>
    -</details>
    -
    -
    -<h2><i class="fa fa-code fa-fw"></i> <span class="WebUIWebPage">Editor</span> WebUI Page</h2>
    -<details><summary></summary>
    -<p>
    -This section lists the settings that are updated in the WebUI's
    -<span class="WebUIWebPage">Editor</span> page.
    -A typical page is below:
    -</p>
    -<img allsky="true" src="EditorPage.png" class="imgBorder imgCenter" title="Typical Editor page" alt="Editor" loading="lazy">
    -<p>
    -This page allows you to edit the <span class="dropdown">config.sh</span> file,
    -and if you have a local and/or remote Allsky Website installed, the
    -<span class="dropdown">ftp-settings.sh</span>,
    -<span class="dropdown">configuration.json</span>, and
    -<span class="dropdown">remote_configuration.json</span> files can also be edited.
    -Further, if you have a <span class="dropdown">endOfNight_additionalSteps.sh</span> file,
    -it can also be edited.
    -
    -<blockquote class="warning">
    -The <span class="dropdown">endOfNight_additionalSteps.sh</span> file will no longer
    -be supported in the next version of Allsky.
    -If you use this file, please move your code to the <strong>Script</strong> module
    -in the <strong>Night to Day Transition Flow</strong>,
    -then remove this file.
    -See the <a allsky="true" href="/documentation/modules/modules.html">Module</a>
    -documentation for more details.
    -</blockquote>
    -</p>
    -<p>
    -The table below describes the settings in the
    -<span class="dropdown">config.sh</span> file.
    -<br>
    -Settings in the <span class="dropdown">ftp-settings.sh</span> file are described in the
    -<a allsky="true" href="allskyWebsite.html#ftp-settings">ftp-settings.sh settings</a> page.
    -<br>
    -Settings in the <span class="dropdown">configuration.json</span> files are described in the
    -<a allsky="true" href="allskyWebsite.html#configuration.json">Allsky Website Settings</a> page.
    -</p>
    -<p>
    -Information on the color scheme used by the Editor in the screenshot above is
    -<a allsky="true" external="true" href="EditorColors.html" target="_blank" title="Opens in new tab">here </a>.
    -</p>
    -
    -<h3>config.sh settings</h3>
    -<p>
    -These settings let you configure the overall behavior of Allsky,
    -and are updated by clicking on the WebUI's 
    -<span class="WebUIWebPage">Editor</span> page, then selecting
    -<span class="dropdown">config.sh</span> in the drop-down list at the bottom of the page.
    -</p>
    -<details sub><summary></summary>
    -
    -<table role="table">
    -<thead>
    -<tr>
    -	<th>Setting</th>
    -	<th>Default</th>
    -	<th>Description</th>
    -</tr>
    -</thead>
    -<tbody>
    -
    -<tr><td class="note" colspan="3">image.jpg Settings</td></tr>
    -
    -<tr><td><span class="shSetting">IMG_UPLOAD</span></td>
    -	<td>false</td>
    -	<td>Upload the current image to a local or remote Allsky Website?
    -	<br>Ignored if you don't have an Allsky Website.
    +<tr><td id="imeagesremovebadhigh"><span class="WebUISetting">Remove Bad Images Threshold High</span></td>
    +	<td>0.9</td>
    +	<td>Images whose mean brightness is above this will be removed.
    +	Useful values range from <code>0.8</code> to around <code>0.95</code>.
    +	<br>Set to <code>0</code> to disable this check.
     	</td>
     </tr>
    -<tr><td><span class="shSetting">IMG_UPLOAD_ORIGINAL_NAME</span></td>
    -	<td>false</td>
    -	<td>Should the name of the uploaded image be <span class="fileName">image.jpg</span> (if false)
    -	or <span class="fileName">image-YYYYMMDDHHMMSS.jpg</span> (if true)?
    -	<br>This is rarely used and is ignored if you don't have an Allsky Website or
    -	<span class="shSetting">IMG_UPLOAD</span> is false.
    +<tr><td id="imagecreatethumbnails"><span class="WebUISetting">Create Image Thumbnails</span></td>
    +	<td>Yes</td>
    +	<td>Create thumbnails of the images and save in <span class="fileName">~/allsky/images</span>?
    +	If you never look at them via the WebUI's <span class="WebUIWebPage">Images<span> page,
    +	consider changing this to "No".
     	</td>
     </tr>
    -<tr><td><span class="shSetting">IMG_UPLOAD_FREQUENCY</span></td>
    -	<td>1</td>
    -	<td>How often should the current image be uploaded?
    -	This is useful for slow or costly networks.
    -	<br>This only applies if <span class="shSetting">IMG_UPLOAD</span> is "true".
    +<tr><td id="thumbnailsizex"><span class="WebUISetting">Thumbnail Width</span></td>
    +	<td>100</td>
    +	<td>Sets the width of image and video thumbnails.</td>
    +</tr>
    +<tr><td id="thumbnailsizey"><span class="WebUISetting">Thumbnail Height</span></td>
    +	<td>75</td>
    +	<td>Sets the height of image and video thumbnails.
    +	<br>These numbers determine the size of the thumbnails in
    +	<span class="fileName">~/allsky/images</span>
    +	as well as any thumbnails created by the Allsky Website.
    +	<blockquote>
    +	Although changing these will change the size of the thumbnails,
    +	no testing has been done to see if there are negative side effects.
    +	</blockquote>
     	</td>
     </tr>
    -<tr><td><span class="shSetting">IMG_RESIZE</span></td>
    -	<td>false</td>
    -	<td>Resize images before cropping and stretching?
    +<tr><td id="imageresizewidth"><span class="WebUISetting">Image Resize Width</span></td>
    +	<td>0</td>
    +	<td>The width to resize images to.
     	Large sensor cameras like the RPi HQ may need to be resized (i.e., shrunken)
     	in order for timelapses to work.
    -	<br>Typically you'll want the
    -	<span class="shSetting">IMG_WIDTH</span>&nbsp;/&nbsp;<span class="shSetting">IMG_HEIGHT</span>
    -	ratio to be the same as the sensor's width / height ratio,
    -	otherwise images will be distorted.</td>
    -</tr>
    -<tr><td><span class="shSetting">IMG_WIDTH</span></td>
    -	<td>1520</td>
    -	<td>The width of the resized image.
    -	<br>Must be an even number. The default is just an example.
    +	Resizing is done before cropping.
    +	<br>Must be an even number and
    +	<span class="WebUIValue">0</span> disables resizing.
     	</td>
     </tr>
    -<tr><td><span class="shSetting">IMG_HEIGHT</span></td>
    -	<td>2028</td>
    -	<td>The height of the resized image.
    -	<br>Must be an even number. The default is just an example.
    +<tr><td id="imageresizeheight"><span class="WebUISetting">Image Resize<br>Height</span></td>
    +	<td>0</td>
    +	<td>Same as <span class="WebUISetting">Image Resize<br>Width</span>
    +	but for the image height.
    +	<br>Typically you'll want the
    +	<span class="WebUISetting">Width</span>&nbsp;/&nbsp;<span class="WebUISetting">Height</span>
    +	ratio to be the same as the sensor's width / height ratio,
    +	otherwise images will be distorted.
     	</td>
     </tr>
    -<tr><td><span class="shSetting">CROP_IMAGE</span></td>
    -	<td>false</td>
    -	<td>Crop images before stretching?
    +<tr><td id="imagecroptop"><span class="WebUISetting">Image Crop<br>Top</span></td>
    +	<td>0</td>
    +	<td>Number of pixels to crop off the top of an image.
     	This is often used to remove the dark areas around an image when using a fisheye lens.
    -	The image is cropped from the center so you'll need to experiment with the correct settings.
    -	Cropped images on the left or top will likely need the
    -	<span class="WebUISetting">Text X</span> and/or <span class="WebUISetting">Text Y</span>
    -	WebUI settings changed.</td>
    -</tr>
    -<tr><td><span class="shSetting">CROP_WIDTH</span></td>
    -	<td>640</td>
    -	<td>The width of the resulting image.
    -	<br>Must be an even number. The default is just an example.
    +	All the <span class="WebUISetting">Crop</span> number should either
    +	<span class="WebUIValue">0</span> to disable cropping that side of an image,
    +	or a positive number.
     	</td>
     </tr>
    -<tr><td><span class="shSetting">CROP_HEIGHT</span></td>
    -	<td>480</td>
    -	<td>The height of the resulting image.
    -	<br>Must be an even number. The default is just an example.
    -	</td>
    -</tr>
    -<tr><td><span class="shSetting">CROP_OFFSET_X</span></td>
    +<tr><td id="imagecropright"><span class="WebUISetting">Image Crop<br>Right</span></td>
     	<td>0</td>
    -	<td>The X offset to use when cropping.
    -	To move the crop rectangle left, use a negative number.</td>
    +	<td>Number of pixels to crop off the right side of an image.</td>
     </tr>
    -<tr><td><span class="shSetting">CROP_OFFSET_Y</span></td>
    +<tr><td id="imagecropbottom"><span class="WebUISetting">Image Crop<br>Bottom</span></td>
     	<td>0</td>
    -	<td>The Y offset to use when cropping.
    -	To move the crop rectangle up, use a negative number.</td>
    -</tr>
    -<tr><td><span class="shSetting">AUTO_STRETCH</span></td>
    -	<td>false</td>
    -	<td>Stretch the image?
    -	This increases the contrast without saturating highlights or shadows.
    -	<br>
    -	See <a allsky="true" href="/documentation/explanations/exposureGainSaturation.html#stretch">this page</a>
    -	for more information on stretching images.
    -	</td>
    +	<td>Number of pixels to crop off the bottom an image.</td>
     </tr>
    -<tr><td><span class="shSetting">AUTO_STRETCH_AMOUNT</span></td>
    -	<td>10</td>
    -	<td>How much to increase the contrast. 0 is none, 3 is typical, and 20 is a lot.</td>
    -</tr>
    -<tr><td><span class="shSetting">AUTO_STRETCH_MID_POINT</span></td>
    -	<td>10%</td>
    -	<td>Where the maximum change "slope" in contrast should fall in the image
    -	(0% is white; 50% is middle-gray; 100% is black).</td>
    +<tr><td id="imagecropleft"><span class="WebUISetting">Image Crop<br>Left</span></td>
    +	<td>0</td>
    +	<td>Number of pixels to crop off the left side of an image.</td>
     </tr>
     
    -<tr><td><span class="shSetting">RESIZE_UPLOADS</span></td>
    -	<td>false</td>
    -	<td>Resize uploaded pictures?
    -	<br>You may want images on your Pi to have higher resolution than images
    -	uploaded to an Allsky Website.
    -	For example, images from cameras with large sensors won't fit on most monitors
    -	at full resolution, so huge images in some senses are a waste.
    -	</td>
    -</tr>
    -<tr><td><span class="shSetting">RESIZE_UPLOADS_WIDTH</span></td>
    -	<td>962</td>
    -	<td>Sets the width of resized images being uploaded.
    -	<br>Must be an even number. The default is just an example.
    -	</td>
    -</tr>
    -<tr><td><span class="shSetting">RESIZE_UPLOADS_HEIGHT</span></td>
    -	<td>720</td>
    -	<td>Sets the height of resized images being uploaded.
    -	<br>Must be an even number. The default is just an example.
    -	</td>
    -</tr>
    -<tr><td><span class="shSetting">IMG_CREATE_THUMBNAILS</span></td>
    -	<td>true</td>
    -	<td>Create thumbnails of the images and save in <span class="fileName">~/allsky/images</span>?
    -	If you never look at them via the WebUI's <span class="WebUIWebPage">Images<span> page,
    -	consider changing this to "false".
    -	</td>
    -</tr>
    -<tr><td><span class="shSetting">REMOVE_BAD_IMAGES</span></td>
    -	<td>true</td>
    -	<td>Remove corrupt or too bright/too dark images and their
    -	thumbnails before generating keograms, startrails, and timelapse videos?
    -	<br><b>We suggest always leaving this turned on</b>;
    -	if images are being removed that you want to keep, change the
    -	<span class="shSetting">*_THRESHOLD_*<span> values below.</td>
    +<tr><td id="timelapse" class="settingsHeader" colspan="3">Timelapse Settings</td></tr>
    +<tr><td id=dailytimelapse" class="subSettingsHeader" colspan="3">Daily Timelapse</td></tr>
    +<tr><td id="timelapsegenerate"><span class="WebUISetting">Generate</span></td>
    +	<td>Yes</td>
    +	<td>Enable to generate a timelapse video at the end of the night.</td>
     </tr>
    -<tr><td><span class="shSetting">REMOVE_BAD_IMAGES_THRESHOLD_LOW</span></td>
    -	<td>1</td>
    -	<td>Images whose mean brightness is below this percent will be removed.
    -	Set to <code>0</code> to disable this check.
    -	<br>Only applies if <span class="shSetting">REMOVE_BAD_IMAGES</span> is "true".
    +<tr><td id="timelapseupload"><span class="WebUISetting">Upload</span></td>
    +	<td>Yes</td>
    +	<td>Upload the timelapse video to an Allsky Website and/or remote server?
    +	If not set, the timelapse videos can still be viewed via the
    +	<span class="WebUIWebPage">Images</span> link on the WebUI.
    +	<br>Ignored if you you do not have an Allsky Website or remote server.
     	</td>
     </tr>
    -<tr><td><span class="shSetting">REMOVE_BAD_IMAGES_THRESHOLD_HIGH</span></td>
    -	<td>90</td>
    -	<td>Images whose mean brightness is above this percent will be removed (max: 100).
    -	Set to <code>0</code> to disable this check.
    -	<br>Only applies if <span class="shSetting">REMOVE_BAD_IMAGES</span> is "true".
    +<tr><td id="timelapseuploadthumbnail"><span class="WebUISetting">Upload Thumbnail</span></td>
    +	<td>Yes</td>
    +	<td>Upload the timelapse video's thumbnail to your Allsky Website?
    +	Many remote servers don't support thumbnail creation so the thumbnail needs to be
    +	created on the Pi and uploaded.
    +	<br>Not needed if your only Allsky Website is on your Pi.
     	</td>
     </tr>
    -
    -<tr><td class="note" colspan="3">Timelapse Settings</td></tr>
    -
    -<tr><td><span class="shSetting">TIMELAPSE</span></td>
    -	<td>true</td>
    -	<td>Build a timelapse video at the end of the night?</td>
    -</tr>
    -<tr><td><span class="shSetting">TIMELAPSEWIDTH</span></td>
    +<tr><td id="timelapsewidth"><span class="WebUISetting">Width</span></td>
     	<td>0</td>
     	<td>Changes the width of the generated timelapse; must be an even number.
    -	<br><code>0</code> uses the images's full size.
    +	<br><span class="WebUIValue">0</span> uses the images's full size.
     	<br>Large sensor cameras like the RPi HQ often need the timelapse to be shrunk
     	in order for timelapses to work (or the individual images need to be shrunk).
     	</td>
     </tr>
    -<tr><td><span class="shSetting">TIMELAPSEHEIGHT</span></td>
    +<tr><td id="timelapseheight"><span class="WebUISetting">Height</span></td>
     	<td>0</td>
     	<td>Changes the height of the generated timelapse; must be an even number.
    -	<br><code>0</code> uses the images's full size.
    +	<br><span class="WebUIValue">0</span> uses the images's full size.
     	<br>If you change the width and height you'll probably want the resulting aspect ratio
     	to match the original images.
     	</td>
     </tr>
    -<tr><td><span class="shSetting">TIMELAPSE_BITRATE</span></td>
    -	<td>5000k</td>
    +<tr><td id="timelapsebitrate"><span class="WebUISetting">Bitrate</span></td>
    +	<td>5000</td>
     	<td>Bitrate the timelapse video will be created with.
     	Higher values produce better quality video but larger files.
    -	<br>Be sure to include the trailing <code>k</code>.</td>
    +	<br>Do NOT add a trailing <span class="WebUIValue">k</span>.</td>
     </tr>
    -<tr><td><span class="shSetting">FPS</span></td>
    +<tr><td id="timelapsefps"><span class="WebUISetting">FPS</span></td>
     	<td>25</td>
     	<td>The timelapse video
    -	<span class="shSetting">F</span>rames
    -	<span class="shSetting">P</span>er
    -	<span class="shSetting">S</span>econd.
    +	<span class="WebUISetting">F</span>rames
    +	<span class="WebUISetting">P</span>er
    +	<span class="WebUISetting">S</span>econd.
     	<br>Higher numbers produce smoother, but shorter, videos.
     	</td>
     </tr>
    -<tr><td><span class="shSetting">VCODEC</span></td>
    -	<td>libx264</td>
    -	<td>Encoder used to create the timelapse video.
    -	Rarely changed.
    -	</td>
    -</tr>
    -<tr><td><span class="shSetting">PIX_FMT</span></td>
    -	<td>yuv420p</td>
    -	<td>Pixel format.
    -	<br>If you don't know what this is, don't change it.
    -	</td>
    -</tr>
    -<tr><td><span class="shSetting">FFLOG</span></td>
    -	<td>warning</td>
    -	<td>Level of debugging information output when creating a timelapse.
    -	Set to <code>info</code> for more output.
    -	Primarily used for debugging or when tuning the algorithm.
    -	</td>
    -</tr>
    -<tr><td><span class="shSetting">KEEP_SEQUENCE</span></td>
    -	<td>false</td>
    +<tr><td id="timelapsekeepsequence"><span class="WebUISetting">Keep Sequence</span></td>
    +	<td>No</td>
     	<td>Keep the sequence of symbolic links created when creating a timelapse?
     	<br>Primarily used when debugging.
     	</td>
     </tr>
    -<tr><td><span class="shSetting">TIMELAPSE_EXTRA_PARAMETERS</span></td>
    +<tr><td id="timelapseextraparameters"><span class="WebUISetting">Extra Parameters</span></td>
     	<td></td>
    -	<td>Any additional timelapse parameters. Run <code>ffmpeg -?</code> to see the options.</td>
    -</tr>
    -<tr><td><span class="shSetting">UPLOAD_VIDEO</span></td>
    -	<td>false</td>
    -	<td>Upload the timelapse video to your Allsky Website?
    -	If not set, the timelapse videos can still be viewed via the
    -	<span class="WebUIWebPage">Images</span> link on the WebUI.
    -	<br>Ignored if you you do not have an Allsky Website.
    +	<td>Any additional timelapse parameters.
    +	If video quality is poor or videos don't plan on Apple devices,
    +	try adding <span class="WebUIValue">-level 3.1</span>.
    +	<br>Run <code>ffmpeg -?</code> to see the options.
     	</td>
     </tr>
    -<tr><td><span class="shSetting">TIMELAPSE_UPLOAD_THUMBNAIL</span></td>
    -	<td>true</td>
    -	<td>Upload the timelapse video's thumbnail to your Allsky Website?
    -	Many remote servers don't support thumbnail creation so the thumbnail needs to be
    -	created on the Pi and uploaded.
    -	<br>Not needed if your only Allsky Website is on your Pi.
    +
    +<tr><td class="subSettingsHeader" colspan="3">Mini-Timelapse</td></tr>
    +<tr><td class="subSettingsHeader subSettingsHeaderNote" colspan="3">
    +	Several of these settings are similar to the setting with the same
    +	name for daily timelapses.
    +	In those cases, only differences are noted.
     	</td>
     </tr>
    -
    -<tr><td class="note" colspan="3">Mini-Timelapse Settings</td></tr>
    -
    -<tr><td><span class="shSetting">TIMELAPSE_MINI_IMAGES</span></td>
    +<tr><td id="minitimelapsenumimages"><span class="WebUISetting">Number Of Images</span></td>
     	<td>0</td>
     	<td>A "mini-timelapse" only includes the most recent images and is created often.
     	It's a good way to see "recent" activity.
     	This setting determines the number of images in the mini-timelapse.
     	Keep in mind the more images you have the longer it'll take to create the video.
     	30 is a good starting point.
    +	<span class="WebUIValue">0</span> disables mini-timelapse creation.
     	<br><b>Note that each mini-timelapse overwrites the prior one.</b>
     	<br>The following settings only apply if this settings is greater than 0.
     	</td>
     </tr>
    -<tr><td><span class="shSetting">TIMELAPSE_MINI_FORCE_CREATION</span></td>
    -	<td>true</td>
    +<tr><td id="minitimelapseforcecreation"><span class="WebUISetting">Force Creation</span></td>
    +	<td>No</td>
     	<td>Should a mini-timelapse be created even if
    -	<span class="shSetting">TIMELAPSE_MINI_IMAGES</span> images
    +	<span class="WebUISetting">Number Of Images</span> images
     	haven't been taken yet?
     	For example, you want 15 images in each mini-timelapse but only 5 have been taken;
     	should a mini-timelapse still be created?
     	</td>
     </tr>
    -<tr><td><span class="shSetting">TIMELAPSE_MINI_FREQUENCY</span></td>
    +<tr><td id="minitimelapsefrequency"><span class="WebUISetting">Frequency</span></td>
     	<td>5</td>
     	<td>After how many images should the mini-timelapse be created?
     	Slower machines and machines with slow networks should use higher numbers.
     	3&nbsp;-&nbsp;5 works well on a Pi 4 with 4 GB memory.
    -	<br>Every <span class="shSetting">TIMELAPSE_MINI_FREQUENCY</span> images a new
    +	<br>Every <span class="WebUISetting">Frequency</span> images a new
     	mini-timelapse is created using the last
    -	<span class="shSetting">TIMELAPSE_MINI_IMAGES</span> images.
    +	<span class="WebUISetting">Number Of Images</span> images.
     	For example, every 5 frames a new mini-timelapse is created using the most recent 15 images.
     	<p>
     	<blockquote class="warning">
     	Be <strong>very</strong> careful when setting the 
    -	<span class="shSetting">TIMELAPSE_MINI_IMAGES</span> and
    -	<span class="shSetting">TIMELAPSE_MINI_FREQUENCY</span> settings.
    +	<span class="WebUISetting">Number Of Images</span> and
    +	<span class="WebUISetting">Frequency</span> settings.
     	If either or both numbers are too high,
     	or the daytime or nighttime <span class="WebUISetting">Delay</span> is too short,
     	there may not be enough time to finish one mini timelapse creation before
     	the next one starts.
     	If this happens the second one will be aborted and you'll see a System Message in the WebUI
    -	(you may need to refresh your browser page).
    +	(you may need to refresh your browser page to see it).
     	</blockquote>
     	</p>
     	</td>
     </tr>
    -<tr><td><span class="shSetting">TIMELAPSE_MINI_UPLOAD_VIDEO</span></td>
    -	<td>true</td>
    -	<td>Like <span class="shSetting">UPLOAD_VIDEO</span> but for mini-timelapses.
    -	</td>
    +<tr><td id="minitimelapseupload"><span class="WebUISetting">Upload</span></td>
    +	<td>Yes</td>
    +	<td></td>
     </tr>
    -<tr><td><span class="shSetting">TIMELAPSE_MINI_UPLOAD_THUMBNAIL</span></td>
    -	<td>true</td>
    -	<td>Like <span class="shSetting">TIMELAPSE_UPLOAD_THUMBNAIL</span> but for mini-timelapses.
    -	</td>
    +<tr><td id="minitimelapseuploadthumbnails"><span class="WebUISetting">Upload Thumbnail</span></td>
    +	<td>Yes</td>
    +	<td></td>
     </tr>
    -<tr><td><span class="shSetting">TIMELAPSE_MINI_FPS</span></td>
    +<tr><td id="minitimelapsewidth"><span class="WebUISetting">Width</span></td>
    +	<td>0</td>
    +	<td></td>
    +</tr>
    +<tr><td id="minitimelapseheight"><span class="WebUISetting">Height</span></td>
    +	<td>0</td>
    +	<td></td>
    +</tr>
    +<tr><td id="minitimelapsebitrate"><span class="WebUISetting">Bitrate</span></td>
    +	<td>2000</td>
    +	<td>This is normally smaller than a full timelapse to save on processing time.</td>
    +</tr>
    +<tr><td id="minitimelapsefps"><span class="WebUISetting">FPS</span></td>
     	<td>5</td>
    -	<td>Like <span class="shSetting">FPS</span> but for mini-timelapses.
    -	<br>Since mini-timelapses contain a very small number of images,
    -	this setting should be small to avoid very short videos.
    +	<td>Mini-timelapses contain a small number of images so
    +	this setting should be low to avoid very short videos.
     	</td>
     </tr>
    -<tr><td><span class="shSetting">TIMELAPSE_MINI_BITRATE</span></td>
    -	<td>2000k</td>
    -	<td>Like <span class="shSetting">TIMELAPSE_BITRATE</span> but for mini-timelapses.
    -	<br>This is normally smaller than a full timelapse to save on processing time.
    +
    +<tr><td id="bothtimelapses" class="subSettingsHeader" colspan="3">Both Timelapses</td></tr>
    +<tr><td id="timelapsevcodec"><span class="WebUISetting">VCODEC</span></td>
    +	<td>libx264</td>
    +	<td>Encoder used to create the timelapse video.
    +	Rarely changed.
     	</td>
     </tr>
    -<tr><td><span class="shSetting">TIMELAPSE_MINI_WIDTH</span></td>
    -	<td>1014</td>
    -	<td>Like <span class="shSetting">TIMELAPSEWIDTH</span> but for mini-timelapses.
    -	<br>Must be an even number. The default is just an example.
    +<tr><td id="timelapsevpixfmt"><span class="WebUISetting">Pixel format</span></td>
    +	<td>yuv420p</td>
    +	<td>Pixel format.
    +	<br>If you don't know what this is, don't change it.
     	</td>
     </tr>
    -<tr><td><span class="shSetting">TIMELAPSE_MINI_HEIGHT</span></td>
    -	<td>760</td>
    -	<td>Like <span class="shSetting">TIMELAPSEHEIGHT</span> but for mini-timelapses.
    -	<br>Must be an even number. The default is just an example.
    +<tr><td id="timelapsefflog"><span class="WebUISetting">Log Level</span></td>
    +	<td>Warning</td>
    +	<td>Level of debugging information output when creating a timelapse.
    +	Set to <span class="WebUIValue">Info + Warnings + Errors</span> for more output.
    +	Primarily used for debugging or when tuning the algorithm.
     	</td>
     </tr>
     
    -<tr><td class="note" colspan="3">Keogram Settings</td></tr>
    -
    -<tr><td><span class="shSetting">KEOGRAM</span></td>
    -	<td>true</td>
    -	<td>Build a keogram image at the end of the night?</td>
    +<tr><td class="settingsHeader" colspan="3">Keogram and Startrails Settings</td></tr>
    +<tr><td id="keograms" class="subSettingsHeader" colspan="3">Keograms</td></tr>
    +<tr><td id="keogramgenerate"><span class="WebUISetting">Generate</span></td>
    +	<td>Yes</td>
    +	<td>Enable to generat a keogram image at the end of the night.</td>
     </tr>
    -<tr><td><span class="shSetting">KEOGRAM_EXTRA_PARAMETERS</span></td>
    -	<td>various</td>
    -	<td>Additional keogram parameters.
    -	Execute <code>~/allsky/bin/keogram --help</code> for a list of options.</td>
    +<tr><td id="keogramupload"><span class="WebUISetting">Upload</span></td>
    +	<td>Yes</td>
    +	<td>Upload the keogram image to an Allsky Website and/or remote server?</td>
    +</tr>
    +<tr><td id="keogramexpand"><span class="WebUISetting">Expand</span></td>
    +	<td>Yes</td>
    +	<td>Enable to expand keograms to the image width.
    +	Same as the "--image-expand" and "-x" options.
    +	</td>
     </tr>
    -<tr><td><span class="shSetting">UPLOAD_KEOGRAM</span></td>
    -	<td>false</td>
    -	<td>Upload the keogram image to your Allsky Website?</td>
    +<tr><td id="keogramfontname"><span class="WebUISetting">Font Name</span></td>
    +	<td>Simplex</td>
    +	<td>Font name.</td>
     </tr>
    -
    -<tr><td class="note" colspan="3">Startrails Settings</td></tr>
    -
    -<tr><td><span class="shSetting">STARTRAILS</span></td>
    -	<td>true</td>
    -	<td>Build a startrail image at the end of the night?</td>
    +<tr><td id="keogramfontcolor"><span class="WebUISetting">Font Color</span></td>
    +	<td>#fff (white)</td>
    +	<td>Font color.
    +	Same as the "--font-color" and "-C" options.
    +	</td>
     </tr>
    -<tr><td><span class="shSetting">BRIGHTNESS_THRESHOLD</span></td>
    +<tr><td id="keogramfontsize"><span class="WebUISetting">Font Size</span></td>
    +	<td>2</td>
    +	<td>Font size.
    +	Same as the "--font-size" and "-S" options.
    +	</td>
    +</tr>
    +<tr><td id="keogramfontlinethickness"><span class="WebUISetting">Font Line Thickness</span></td>
    +	<td>3</td>
    +	<td>Thickness of the font's line.
    +	Same as the "--font-line" and "-L" options.
    +	</td>
    +</tr>
    +<tr><td id="keogramextraparameters"><span class="WebUISetting">Extra Parameters</span></td>
    +	<td></td>
    +	<td>Optional additional keogram creation parameters.
    +	Execute <code>~/allsky/bin/keogram --help</code> for a list of options.</td>
    +</tr>
    +<tr><td id="startrails" class="subSettingsHeader" colspan="3">Startrails</td></tr>
    +<tr><td id="startrailsgenerate"><span class="WebUISetting">Generate</span></td>
    +	<td>Yes</td>
    +	<td>Enable to generate a startrails image at the end of night.</td>
    +</tr>
    +<tr><td id="startrailsbrightnessthreshold"><span class="WebUISetting">Brightness Threshold</span></td>
     	<td>0.1</td>
    -	<td>Average brightness level above which images are discarded (moon, head lights, aurora, etc.).
    +	<td>Average brightness level above which images are discarded
    +	(moon, head lights, aurora, etc.).
     	If you are only getting very short trails, or none at all, adjust this number.
    -	<br>Values are 0.0 (pure black, filters out nothing) to 1.0 (pure white, uses every image).
    +	<br>Values are 0.0 (pure black, filters out nothing)
    +	to 1.0 (pure white, uses every image).
     	</td>
     </tr>
    -<tr><td><span class="shSetting">STARTRAILS_EXTRA_PARAMETERS</span></td>
    +<tr><td id="startrailsupload"><span class="WebUISetting">Upload</span></td>
    +	<td>Yes</td>
    +	<td>Enable to upload the startrails image to an Allsky Website and/or remote server.</td>
    +</tr>
    +<tr><td id="startrailsextraparameters"><span class="WebUISetting">Extra Parameters</span></td>
     	<td></td>
    -	<td>Additional startrails parameters.
    +	<td>Optional additional startrails creation parameters.
     	Execute <code>~/allsky/bin/startrails --help</code> for a list of options.</td>
     </tr>
    -<tr><td><span class="shSetting">UPLOAD_STARTRAILS</span></td>
    -	<td>false</td>
    -	<td>Upload the startrails image to your Allsky Website?</td>
    -</tr>
     
    -<tr><td class="note" colspan="3">Other Settings</td></tr>
    +<tr><td id="websitesandremoteserver" class="settingsHeader" colspan="3">Websites and Remote Server Settings</td></tr>
    +<tr><td class="settingsHeader settingsHeaderNote" colspan="3">
    +	Allsky supports uploading files to a local (i.e., on your Pi) Website,
    +	a remote (i.e., not on your Pi) Website,
    +	and/or a remote server that is NOT an Allsky Website.</td>
    +</tr>
     
    -<tr><td><span class="shSetting">THUMBNAIL_SIZE_X</span></td>
    -	<td>100</td>
    -	<td>Sets the width of thumbnails.</td>
    +<tr><td id="imagesresizeuploadswidth"><span class="WebUISetting">Resize Uploaded Images Width</span></td>
    +	<td>0</td>
    +	<td>Set to an even number to resize images that will be uploaded.
    +	This decreases the size of uploaded files and saves network bandwidth.
    +	Further, images from cameras with large sensors won't fit on most monitors
    +	at full resolution, so in effect it's a waste to have large images.
    +	Set to <span class="WebUIValue">0</span> to disable resizing.
    +	</td>
     </tr>
    -<tr><td><span class="shSetting">THUMBNAIL_SIZE_Y</span></td>
    -	<td>75</td>
    -	<td>Sets the height of thumbnails.
    -	<br>These numbers determine the size of the thumbnails in
    -	<span class="fileName">~/allsky/images</span>
    -	as well as any thumbnails created by the Allsky Website.
    -	<blockquote>Although changing these will change the size of the thumbnails,
    -	no testing has been done to see if there are negative side effects.</blockquote>
    +<tr><td id="imagesresizeuploadsheight"><span class="WebUISetting">Resize Uploaded Images Height</span></td>
    +	<td>0</td>
    +	<td>Same as <span class="WebUISetting">Resize Uploaded Images Width</span>
    +	but for the height.
    +	If that setting is <span class="WebUIValue">0</span> this setting must be as well.
    +	</td>
    +</tr>
    +<tr><td id="imageuploadfrequency"><span class="WebUISetting">Upload Every X Images</span></td>
    +	<td>1</td>
    +	<td>How often should the current image be uploaded?
    +	This is useful for slow or costly networks.
    +	<span class="WebUIValue">0</span> disables uploading images
    +	Note that startrails, keograms, and timelapse videos have their own "upload" settings.
    +	</td>
    +</tr>
    +<tr><td id="displaysettings"><span class="WebUISetting">Display Settings</span></td>
    +	<td>No</td>
    +	<td>People sometimes ask others what settings they are using.
    +	Enable this setting to add a link to your Allsky Website's right-side
    +	popout that displays your settings.
    +	<br>This only works if you are running the Allsky Website.
     	</td>
     </tr>
     
    -<tr><td><span class="shSetting">DAYS_TO_KEEP</span></td>
    -	<td>14</td>
    -	<td>Number of days of images and videos in
    -	<span class="fileName">~/allsky/images</span> to keep.
    -	Any directory older than this many days will be deleted at each end of night.
    -	Set to <code>0</code> to keep ALL days' data;
    -	you will need to manually manage disk space.</td>
    +<tr><td "id=localwebsite" class="subSettingsHeader" colspan="3">Local Website</td></tr>
    +<tr><td id="uselocalwebsite"><span class="WebUISetting">Use Local Website</span></td>
    +	<td>No</td>
    +	<td>Enable to use a <b>local</b> Allsky Website.
    +	No other settings are needed for a local Website.
    +	<br>
    +	If you were using a local Website and disable it you will be asked if you
    +	want to keep or remove the saved images, keograms, startrails, and timelapse videos.
    +	Either way, the Website itself is not not removed nor are its settings so you can
    +	easily re-enable it and pick up where you left off.
    +	</td>
     </tr>
    -<tr><td><span class="shSetting">WEB_DAYS_TO_KEEP</span></td>
    -	<td>0</td>
    -	<td>Number of days of web data to keep in
    -	<span class="fileName">~/allsky/html/allsky</span>.
    -	Any image, video, or thumbnail older than this many days will be deleted at each end of night.
    -	<code>0</code> disables the check and keeps ALL days' web data;
    +<tr><td id="daystokeeplocalwebsite"><span class="WebUISetting">Days To Keep on Pi Website</span></td>
    +	<td>14</td>
    +	<td>Any image, video, or thumbnail older than this many days
    +	will be deleted at each end of night.
    +	<span class="WebUIValue">0</span> disables the check and keeps ALL days' web data;
     	you will need to manually manage disk space.
    -	<blockquote>This only applies to Allsky Websites on the Pi.</blockquote>
     	</td>
     </tr>
    -<tr><td><span class="shSetting">WEBUI_DATA_FILES</span></td>
    +
    +<tr><td id="remotewebsite" class="subSettingsHeader" colspan="3">Remote Website</td></tr>
    +<tr><td id="useremotewebsite"><span class="WebUISetting">Use Remote Website</span></td>
    +	<td>No</td>
    +	<td>Enable to use a <b>remote</b> Allsky Website.
    +	Enable the Website BEFORE you
    +	<a allsky="true" external="true" href="/documentation/installations/AllskyWebsite.html">install</a>
    +	it since the installation needs the settings below.
    +	</td>
    +</tr>
    +<tr><td id="remotewebsiteimagedir"><span class="WebUISetting">Image Directory</span></td>
     	<td></td>
    -	<td>One or more colon-separated full path names to text files that contain user-supplied
    -	data to display on the WebUI's <span class="WebUIWebPage">System</span> page.
    -	See the <a allsky="true" href="/documentation/eplanations/SystemPageAdditions.html">WEBUI_DATA_FILES page</a>
    -	for more information and an example of this setting.</td>
    +	<td>Name of the directory the current image should be uploaded to.
    +		<blockquote>
    +		This setting is a major cause of confusion for many users.
    +		If you don't know what to enter,
    +		ask the person or company supporting the server or look in the
    +		<a allsky="true" external="true" href="/documentation/troubleshooting/uploads.html">
    +		Troubleshooting -> Uploads</a> page for more information.
    +		</blockquote>
    +	</td>
     </tr>
    -<tr><td><span class="shSetting">UHUBCTL_PATH</span></td>
    +<tr><td id="remotewebsiteprotocol"><span class="WebUISetting">Protocol</span></td>
    +	<td>ftps</td>
    +	<td>Specifies how files should be uploaded to the remote Website.
    +	<ul class="minimalPadding">
    +		<li><code>ftps</code> -
    +			uses secure File Transfer Protocol (FTPs) to upload to a remote server.
    +		<li><code>sftp</code> -
    +			uses SSH file transfer to upload to a remote server.
    +		<li><code>ftp</code> -
    +			uses (insecure) File Transfer Protocol (FTP) to upload to a remote server.
    +			<blockquote class="warning">
    +				<code>ftp</code> is unsecure;
    +				please use 
    +				<code>ftps</code> or
    +				<code>sftp</code> instead.
    +			</blockquote>
    +		<li><code>scp</code> -
    +			uses secure cp (copy) to copy the file to a remote server.
    +		<li><code>rsync</code> -
    +			uses rsync to copy the file to a remote server.
    +		<li><code>s3</code> -
    +			copies the file to an Amazon Web Services (AWS) server.
    +		<li><code>gcs</code> -
    +			copies the file to a Google Cloud Storage (GCS) server.
    +	</ul>
    +	<p>Some of the settings below only apply to certain protocols.</p>
    +	</td></tr>
    +<tr><td id="REMOTEWEBSITE_HOST"><span class="WebUISetting">Server Name</span></td>
    +	<td></td>
    +	<td>Name of the remote server.
    +		Note that this is normally NOT the same as the URL used to access the server.
    +		If you don't know the name of the server,
    +		ask the person or company supporting the server.
    +	</td></tr>
    +<tr><td id="REMOTEWEBSITE_PORT"><span class="WebUISetting">Port</span></td>
    +	<td></td>
    +	<td>(ftp protocols only) Optional port required by the server.
    +		This is rarely needed as the software can usually determine what port to use.
    +	</td></tr>
    +<tr><td id="REMOTEWEBSITE_USER"><span class="WebUISetting">User Name</span></td>
    +	<td></td>
    +	<td>The username of the login on the remote Website.
    +	</td></tr>
    +<tr><td id="REMOTEWEBSITE_PASSWORD"><span class="WebUISetting">Password</span></td>
     	<td></td>
    -	<td>If you have the <code>uhubctl</code> command installed enter its path name.
    -	<code>uhubctl</code> resets the USB bus and can sometimes eliminate ASI_ERROR_TIMEOUTs
    -	and/or "find" a camera that isn't showing up.
    +	<td>The password of the login on the remote Website.
    +	</td></tr>
    +<tr><td id="REMOTEWEBSITE_LFTP_COMMANDS"><span class="WebUISetting">FTP Commands</span></td>
    +	<td></td>
    +	<td>(ftp protocols only) Optional colon-separated list of commands to send to the ftp server
    +		prior to transfering files.
    +		If you have problems uploading to an ftp server you'll often need to add
    +		something to this field - see the
    +		<a allsky="true" external="true" href="/documentation/troubleshooting/uploads.html">Troubleshooting -> Uploads</a>
    +		page for more information.
    +	</td></tr>
    +<tr><td id="REMOTEWEBSITE_SSH_KEY_FILE"><span class="WebUISetting">SSH Key File</span></td>
    +	<td></td>
    +	<td>(scp and rsync protocols only) Path to the SSH key.
    +	<p>
    +	You need to set up SSH key authentication on your server.
    +	First, generate an SSH key on your Pi:
    +<pre>ssh-keygen -t rsa</pre>
    +	When prompted, leave the default filename, and use an empty passphrase.
    +	</p>
    +	Then, copy the generated key to your server:
    +<pre>ssh-copy-id remote_username@remote_server</pre>
    +	Replace <code>remote_username</code> with the login name on the remote server
    +	and replace <code>remote_server</code> with the name or IP address of the remote server.
    +	If you are prompted to <code>...continue connecting...</code>, enter <code>yes</code>.
    +	<p>
    +	The private SSH key will be stored in
    +	<span class="fileName">~/.ssh</span> (default filename is <span class="fileName">id_rsa</span>).
    +	</p>
    +	<p>
    +	If you are using SSH Key with Amazon's Lightsail,
    +	copy the <span class="fileName">ssh-key.pem</span> file to your Pi,
    +	for example, in <span class="fileName">~</span>,
    +	then execute <code class="nowrap">chmod 400 ~/ssh-key.pem</code> and set:
    +	<ul class="minimalPadding">
    +		<li><span class="WebUISetting">Protocol</span> to <code>sftp</code>
    +		<li><span class="WebUISetting">Server Name</span> to <code>remote host name</code>
    +		<li><span class="WebUISetting">User Name</span> to <code>remote user name</code>
    +		<li><span class="WebUISetting">Password</span> to <code>n/a</code>
    +		<li><span class="WebUISetting">FTP Commands</span> to
    +			<code>set sftp:connect-program 'ssh -a -x -i /home/pi/ssh-key.pem'</code>
    +	</ul>
    +	</p>
    +	</td></tr>
    +<tr><td id="REMOTEWEBSITE_AWS_CLI_DIR"><span class="WebUISetting">AWS CLI Directory</span></td>
    +	<td></td>
    +	<td>(s3 protocol only) Directory on the Pi where the AWS Command-Line Interface (CLI)
    +	tools are installed,
    +	typically <span class="fileName">${HOME}/.local/bin</span>.
    +	You need to install tools:
    +<pre>
    +pip3 install awscli --upgrade --user
    +export PATH="${HOME}/.local/bin:${PATH}"
    +aws configure
    +</pre>
    +	When prompted, enter a valid access key ID, Secret Access Key, and Default region name,
    +	for example, "us-west-2".
    +	Set the Default output format to "json" when prompted.
    +	</td></tr>
    +<tr><td id="REMOTEWEBSITE_S3_BUCKET"><span class="WebUISetting">S3 Bucket</span></td>
    +	<td></td>
    +	<td>(s3 protocol only) Name of the S3 bucket where files will be uploaded to
    +	(must be in Default region specified above).
    +	Suggested name is <span class="WebUIValue">allskybucket</span>.
    +	You may want to turn off or limit bucket versioning to avoid consuming lots of
    +	space with multiple versions of the "image.jpg" files.
    +	</td></tr>
    +<tr><td id=REMOTEWEBSITE_S3_ACL""><span class="WebUISetting">S3 ACL</span></td>
    +	<td>private</td>
    +	<td>(s3 protocol only) S3 Access Control List (ACL).
    +	<br>
    +	If you want to serve your uploaded files vis http(s),
    +	change this to <code>public-read</code>.
    +	You will need to ensure the S3 bucket policy is configured to allow public access to
    +	objects with a <code>public-read</code> ACL.
    +	<br>You may need to set a CORS policy in S3 if the files are to be accessed by
    +	Javascript from a different domain.
    +	</td></tr>
    +<tr><td id="REMOTEWEBSITE_GCS_BUCKET"><span class="WebUISetting">GCS Bucket</span></td>
    +	<td></td>
    +	<td>(gcs protocol only) Name of the GCS bucket where files will be uploaded to.
    +	Suggested name is <span class="WebUIValue">allskybucket</span>.
    +	<br>
    +	You need to install the <code>gsutil</code> command which is part of the Google Cloud SDK.
    +	See installation instructions
    +	<a external="true" href="https://cloud.google.com/storage/docs/gsutil_install">here</a>.
    +	<br>
    +	NOTE: The <code>gsutil</code> command must be installed somewhere in the standard ${PATH},
    +	usually in <span class="fileName">/usr/bin</span>.
    +	Make sure you authenticate the cli tool with the correct user as well.
    +	</td></tr>
    +<tr><td id="REMOTEWEBSITE_GCS_ACL"><span class="WebUISetting">GCS ACL</span></td>
    +	<td>private</td>
    +	<td>(gcs protocol only) GCS Access Control List (ACL).
    +	You can use any one of the predefined ACL rules found
    +	<a external="true" href="https://cloud.google.com/storage/docs/access-control/lists#predefined-acl">here</a>.
    +	To access files over https, set this to <code>publicRead</code>.
    +	</td></tr>
    +
    +<tr><td id=remotewebsiteverimageuploadoriginalname""><span class="WebUISetting">Upload With Original Name</span></td>
    +	<td>No</td>
    +	<td>Determines the name of the uploaded image:
    +	<span class="fileName">image.jpg</span> (if No)
    +	or <span class="fileName">image-YYYYMMDDHHMMSS.jpg</span> (if Yes).
    +	<br>This is rarely used since changing the image name will make it unviewable.
     	</td>
     </tr>
    -<tr><td><span class="shSetting">UHUBCTL_PORT</span></td>
    -	<td>2</td>
    -	<td>Enter the USB port the camera is on.
    -	Port 1 is USB 2.0 and port 2 is USB 3.0 on a Pi 4.
    -	<br>Ignored if <span class="shSetting">UHUBCTL_PATH</span> is not set.</td>
    +<tr><td id="remotewebsitevideodestinationname"><span class="WebUISetting">Remote Video File Name</span></td>
    +	<td></td>
    +	<td>The name to give to the remote timelapse video file.
    +		If not specified it's the same as the file on your Pi.
    +	<br>This is rarely used since changing the image name will make it unviewable.
    +	</td></tr>
    +<tr><td id="remotewebsitekeogramdestinationname"><span class="WebUISetting">Remote Keogram File Name</span></td>
    +	<td></td>
    +	<td>The name to give to the remote keogram file.
    +		If not specified it's the same as the file on your Pi.
    +	<br>This is rarely used since changing the image name will make it unviewable.
    +	</td></tr>
    +<tr><td id="remotewebsitestartrailsdestinationname"><span class="WebUISetting">Remote Startrails File Name</span></td>
    +	<td></td>
    +	<td>The name to give to the remote startrails file.
    +		If not specified it's the same as the file on your Pi.
    +	<br>This is rarely used since changing the image name will make it unviewable.
    +	</td></tr>
    +<tr><td id="remotewebsiteurl"><span class="WebUISetting">Website URL</span></td>
    +	<td></td>
    +	<td>Your website's URL, for example
    +	<span class="WebUIValue">https://www.mywebsite.com/allsky</span>.
    +	<blockquote>
    +	If a <span class="WebUISetting">Website URL</span> is specified,
    +	the <span class="WebUISetting">Image URL</span> must also be specified, and vice versa.
    +	<br>Both URLs must begin with
    +	<span class="WebUIValue">http</span> or <span class="WebUIValue">https</span>.
    +	</blockquote>
    +	</td></tr>
    +<tr><td id="remotewebsiteimageurl"><span class="WebUISetting">Image URL</span></td>
    +	<td></td>
    +	<td>The URL to your allsky image, for example
    +	<span class="WebUIValue">https://www.mywebsite.com/allsky/image.jpg</span>.
    +		Normally this will be the <span class="WebUISetting">Website URL</span> followed by
    +		<code>image.jpg</code>.
    +		<br>Use the more secure "https" over "http" if possible.
    +	</td></tr>
    +
    +<tr><td id="remoteserver" class="subSettingsHeader" colspan="3">Remote Server</td></tr>
    +<tr><td class="subSettingsHeader subSettingsHeaderNote" colspan="3">
    +	These settings are the same as for the Remote Website so are not described.</td>
    +</tr>
    +<tr><td id="useremoteserver"><span class="WebUISetting">Use Remote Server</span></td>
    +	<td>No</td>
    +	<td>Enable to use a remote server which is NOT running the Allsky Website software.
    +		Files will be copied to this server which normally will be a private website
    +		where you want to display the most recent images along with other,
    +		non-Allsky information.
    +		For example, if you have an observatory website and want to show the latest image,
    +		you would use the settings below.
    +	</td></tr>
    +<tr><td id="remoteserverimagedir"><span class="WebUISetting">Image Directory</span></td>
    +	<td></td>
    +	<td></td></tr>
    +<tr><td id="remoteserverprotocol"><span class="WebUISetting">Protocol</span></td>
    +	<td>ftps</td>
    +	<td></td></tr>
    +<tr><td id="REMOTESERVER_HOST"><span class="WebUISetting">Server Name</span></td>
    +	<td></td>
    +	<td></td></tr>
    +<tr><td id="REMOTESERVER_PORT"><span class="WebUISetting">Port</span></td>
    +	<td></td>
    +	<td></td></tr>
    +<tr><td id="REMOTESERVER_USER"><span class="WebUISetting">User Name</span></td>
    +	<td></td>
    +	<td></td></tr>
    +<tr><td id="REMOTESERVER_PASSWORD"><span class="WebUISetting">Password</span></td>
    +	<td></td>
    +	<td></td></tr>
    +<tr><td id="REMOTESERVER_LFTP_COMMANDS"><span class="WebUISetting">FTP Commands</span></td>
    +	<td></td>
    +	<td></td></tr>
    +<tr><td id="REMOTESERVER_SSH_KEY_FILE"><span class="WebUISetting">SSH Key File</span></td>
    +	<td></td>
    +	<td></td></tr>
    +<tr><td id="REMOTESERVER_ASW_CLI_DIR"><span class="WebUISetting">AWS CLI Directory</span></td>
    +	<td></td>
    +	<td></td></tr>
    +<tr><td id="REMOTESERVER_S3_BUCKET"><span class="WebUISetting">S3 Bucket</span></td>
    +	<td></td>
    +	<td></td></tr>
    +<tr><td id="REMOTESERVER_S3_ACL"><span class="WebUISetting">S3 ACL</span></td>
    +	<td>private</td>
    +	<td></td></tr>
    +<tr><td id="REMOTESERVER_GCS_BUCKET"><span class="WebUISetting">GCS Bucket</span></td>
    +	<td></td>
    +	<td></td></tr>
    +<tr><td id="REMOTESERVER_GCS_ACL"><span class="WebUISetting">GCS ACL</span></td>
    +	<td>private</td>
    +	<td></td></tr>
    +<tr><td id="remoteserverimageuploadoriginalname"><span class="WebUISetting">Upload With Original Name</span></td>
    +	<td>No</td>
    +	<td>
    +	<br>Unless you are using the remote server as a backup,
    +	this is <b>rarely</b> used since changing the image name will make it unviewable.
    +	</td></tr>
    +<tr><td id="remoteservervideodestinationname"><span class="WebUISetting">Remote Video File Name</span></td>
    +	<td>allsky.mp4</td>
    +	<td>
    +	<br>Unless you are using the remote server as a backup,
    +	this is <b>usually</b> used so the server knows the file name.
    +	</td></tr>
    +<tr><td id=remoteserverkeogramdestinationname""><span class="WebUISetting">Remote Keogram File Name</span></td>
    +	<td>keogram.jpg</td>
    +	<td>
    +	<br>Unless you are using the remote server as a backup,
    +	this is <b>usually</b> used so the server knows the file name.
    +	</td></tr>
    +<tr><td id=remoteserverstartrailsdestinationname""><span class="WebUISetting">Remote Startrails File Name</span></td>
    +	<td>startrails.jpg</td>
    +	<td>
    +	<br>Unless you are using the remote server as a backup,
    +	this is <b>usually</b> used so the server knows the file name.
    +	</td></tr>
    +
    +
    +<tr><td id="allskymap" class="settingsHeader" colspan="3">Allsky Map Settings</td></tr>
    +<tr><td class="settingsHeader settingsHeaderNote" colspan="3">
    +	If you want your allsky camera's location to display on the
    +	<a external="true" href='https://www.thomasjacquin.com/allsky-map'>Allsky map</a>,
    +	the information in this section will be sent to the map server every other
    +	morning to ensure it's fresh.
    +	The server automatically removes old data.
    +	Note that only a limited number of updates per day are allowed to catch bogus updates.
    +	</td>
     </tr>
     
    -<tr><td class="note" colspan="3">Do not change anything lower in the file</td></tr>
    -</tbody>
    -</table>
    -</details>
    +<tr><td id="showonmap"><span class="WebUISetting">Show on Map</span></td>
    +	<td>No</td>
    +	<td>Enable to have your camera appear on the
    +	<a external="true" href='https://www.thomasjacquin.com/allsky-map'>Allsky map</a>.
    +	<br><b>If off, the following settings are ignored.</b></td></tr>
     
    +<tr><td id="location"><span class="WebUISetting">Location</span> <span class="AW">AW</span></td>
    +	<td></td>
    +	<td>The location of your camera.
    +	You can put any level of detail you want.</td></tr>
    +<tr><td id="owner"><span class="WebUISetting">Owner</span> <span class="AW">AW</span></td>
    +	<td></td>
    +	<td>The owner of the camera - your name, an association name, an observatory, etc.</td></tr>
    +<tr><td id="camera"><span class="WebUISetting">Camera</span> <span class="AW">AW</span></td>
    +	<td></td>
    +	<td>The type and model of your camera, for example: <b>ZWO ASI224MC</b> or <b>RPi HQ</b>.
    +	This field is required and a default value is set during Allsky installation.</td></tr>
    +<tr><td id="lens"><span class="WebUISetting">Lens</span> <span class="AW">AW</span></td>
    +	<td></td>
    +	<td>The lens you're using on your camera, for example: <b>Arecont 1.55</b>.</td></tr>
    +<tr><td id="computer"><span class="WebUISetting">Computer</span> <span class="AW">AW</span></td>
    +	<td></td>
    +	<td>The computer running your allsky camera, for example: <b>Raspberry Pi 3</b>.
    +	This field is required and a default value is set during Allsky installation.</td></tr>
     
    -<!--
    -<h2>[MORE TO COME ...]</h2>
    -<details><summary></summary>
    -<br>
    +<tr><td id=webusconfiguration" class="settingsHeader" colspan="3">WebUI Configuration</td></tr>
    +<tr><td id="imagessortorder"><span class="WebUISetting">Images Sort Order</span></td>
    +	<td>Ascending</td>
    +	<td>Determines how the images for a day in the
    +	the WebUI's <span class="WebUIWebPage">Images</span> page are displayed.
    +	<br><span class="WebUIValue">Ascending</span>
    +		sorts oldest image to newest.
    +	<br><span class="WebUIValue">Descending</span>
    +		sorts newest (i.e., most recent) image to oldest.
    +	</td></tr>
    +<tr><td id="showupdatedmessage"><span class="WebUISetting">Show Updated Message</span></td>
    +	<td>Yes</td>
    +	<td>Determines whether or not the
    + 	<span class='alert-info'>Daytime images updated every...</span>
    +	message in the <span class='WebUIWebPage'>Live View</span> page is shown.
    +	</td></tr>
    +<tr><td id="uselogin"><span class="WebUISetting">Require WebUI Login</span></td>
    +	<td>Yes</td>
    +	<td>Determines if you need to log into the WebUI.
    +	<b>If you Pi is accessible on the Internet, do NOT disable this!!</b></td>
    +</tr>
    +<tr><td id="notificationimages"><span class="WebUISetting">Notification Images</span></td>
    +	<td>Yes</td>
    +	<td>Displays notification images, e.g., "Camera off during day" when
    +	daytime images are not being taken.</td>
    +</tr>
    +<tr><td id="webuidatafiles"><span class="WebUISetting">System Page Additions</span></td>
    +	<td></td>
    +	<td>One or more colon-separated full path names to text files
    +	that contain user-supplied data to display on the WebUI's
    +	<span class="WebUIWebPage">System</span> page.
    +	See <a allsky="true" external="true" href="/documentation/explanations/SystemPageAdditions.html">
    +		System Page Additions</a>
    +	for more information and an example of this setting.
    +	</td></tr>
     
    -These color balances work for some people on Buster (raspistill):
    -<ul>
    -<li>RPi HQ Red: 3.24, Blue: 1.47
    -<li>RPi HQ no AWB, Red: 2.8, Blue: 2.2, Saturation: 40
    -</ul>
    +<tr><td class="settingsHeader" colspan="3">Camera</td></tr>
    +<tr><td id="cameratype"><span class="WebUISetting">Camera Type</span></td>
    +	<td></td>
    +	<td>The type of camera you are using: ZWO or RPi.
    +	<br>To select a camera of a different <span class="WebUISetting">Camera Type</span>:
    +	<ol class="minimalPadding">
    +		<li>Select the Type.
    +		<li>Save the change via the
    +			<span class="btn btn-primary btn-not-real btn-small">Save changes</span> button.
    +		<li>Update the <span class="WebUISetting">Camera Model</span> as desired.
    +	</ol>
    +	If you connect or disconnect a camera on the Pi,
    +	select <span class="dropdown">Refresh</span>
    +	to update the list of camera types and models.
    +	</td></tr>
    +<tr><td id="cameramodel"><span class="WebUISetting">Camera Model</span></td>
    +	<td></td>
    +	<td>The model of camera you are using.
    +	Note this list is only the models of
    +	the current <span class="WebUISetting">Camera Type</span>.
    +	</td></tr>
    +
    +
    +</tbody>
    +</table>
     
    -Luc Bodson uses these with RPi HQ and libcamera:
    -<ul>
    -<li>IR filter in place: Saturation: 1.4, AWB, Red: 3.47, Blue: 1.6
    -<li>IR filter removed: Saturation: 0.5, no AWB, Red: 1.0, Blue 1.6
    -</ul>
    --->
    -</details>
     
     </div><!-- Layout-main -->
     </div><!-- Layout -->
    diff --git a/html/documentation/settings/allskyWebsite.html b/html/documentation/settings/allskyWebsite.html
    index 22eb9606c..23468ac28 100644
    --- a/html/documentation/settings/allskyWebsite.html
    +++ b/html/documentation/settings/allskyWebsite.html
    @@ -14,6 +14,7 @@
     			content: "Allsky Website Settings";
     		} 
     	</style>
    +	<link href="../css/custom.css" rel="stylesheet">
     	<link href="../css/documentation.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
     	<script src="../js/all.min.js"></script>
    @@ -26,361 +27,118 @@
     <div class="Layout-main markdown-body" id="mainContents">
     
     <p>
    -The Allsky Website allows you to display your most recent captured image on a website,
    -either on your Pi or on another machine.
    +The Allsky Website allows you to display your most recent captured image on a website
    +on your Pi, on another machine, or on both.
     Saved timelapse videos, keograms, and startrails can also be viewed.
     Constellations and other objects can be overlayed on the image,
     and aurora activity can be listed.
     </p>
     <p>
    -Make sure you follow the
    -<a allsky="true" href=../installations/AllskyWebsite.html">Allsky Website Installation Instructions</a>
    -exactly to install the Allsky Website on your Pi and/or a remote server.
    +For a <strong>local</strong> Allsky Website you only need to
    +configure and enable it as described below; no installation is needed.
     </p>
     <p>
    -To configure the Website:
    -<ol>
    -<li>In the WebUI, click on the <span class="WebUIWebPage">Editor</span> link in the menu.
    -
    -<li>If you have not already done so while installing the Website,
    -	in the drop-down at the bottom of the page, select
    -	<span class="dropdown">ftp-settings.sh</span>
    -	to specify the settings to upload image,
    -	keogram, startrails, and timelapse 
    -	files to your local and/or remote Allsky Website.
    -	<br>See the <a href="#ftp-settings">ftp-settings.sh file</a> section below
    -	for the various settings and their meanings.
    -
    -<li>In the drop-down at the bottom of the page, select
    -	one of the following, depending on which Website you want to configure:
    -	<ul>
    -	<li><span class="dropdown">configuration.json (local Allsky Website)</span> or
    -	<li><span class="dropdown">remote_configuration.json (remote Allsky Website)</span>
    -		(only if you have a remote Website)
    -	</ul>
    -	See the <a href="#configuration.json">configuration.json files</a> section below
    -	for a description of the settings in those files.
    -</ol>
    -</p>
    -<p>
    -The sections below list all the settings, their default values, and a description.
    -Information on the color scheme used by the Editor is
    -<a allsky="true" external="true" href="EditorColors.html" target="_blank" title="Opens in new tab">here</a>.
    +Before you configure a <strong>remote</strong> Website you must
    +first install it - see the
    +<a allsky="true" external="true"
    +	href="../installations/AllskyWebsite.html">Allsky Website Installation Instructions</a>.
    +Then return to this page.
     </p>
     
    +Steps to configure an Allsky Website are below.
     
    -<h2 id="ftp-settings">ftp-settings.sh file</h2>
    -<p>
    -In order to upload files to your Allsky Website (on the Pi and/or a remote server),
    -connection details must be specified by clicking on the WebUI's 
    -<span class="WebUIWebPage">Editor</span> page, then selecting
    -<span class="dropdown">ftp-settings.sh</span> in the drop-down list at the bottom of the page.
    -</p>
    -<details><summary></summary>
    -<p>
    -Notes:
    -<ul>
    -<li>
    -	In the text below, "local" refers to an Allsky Website that's on the Pi
    -	and "remote" refers to an Allsky Website <b>not</b> on the Pi.
    -	<blockquote class="warning">
    -	<br>
    -	Directories on the <strong>Pi</strong> are created during Allsky Website installation, but
    -	<strong>YOU</strong> must create the <strong>remote</strong> directories.
    -	<br>&nbsp;
    -	</blockquote>
    -<li>
    -	The <span class="shSetting">WEB_*_DIR</span>
    -	settings below (e.g., <span class="shSetting">WEB_VIDEOS_DIR</span>)
    -	are <strong>only</strong> used if you have an Allsky Website on the Pi
    -	<strong>AND</strong> a remote Allsky Website, and you want files copied to both locations.
    -	In this case, see the
    -	<a href="#upload-combinations">example at the end of the table below</a>
    -	for what settings to use.
    -<li>
    -	By default, the destination file name is the same as the file being uploaded.
    -	The <span class="shSetting">*_DESTINATION_NAME</span> settings below are used
    -	to specify a DIFFERENT destination name.
    -	For example, if the file being uploaded is
    -	<span class="fileName">allsky-20210710.mp4</span> you may want it
    -	called <span class="fileName">allsky.mp4</span> on the remote web server
    -	so the name is always the same.
    -	In that case, set
    -	<span class="shSetting">VIDEOS_DESTINATION_NAME</span><span class="editorSpecial">=</span><span class="editorString">"allsky.mp4"</span>
    -	(don't forget the file name extension like .mp4, .jpg, etc.).
    -	If you want the destination file name to be the <b>same</b> as what's being uploaded,
    -	leave the <span class="shSetting">*_DESTINATION_NAME</span> blank.
    -</ul>
    -</p>
    -<br>
    -
    -<table role="table">
    -<thead>
    -<tr><th>Setting</th><th>Default</th><th>Description</th></tr>
    -</thead>
    -<tbody>
    -<tr><td><span class="shSetting">PROTOCOL</span></td> <td></td>
    -	<td>How the file should be uploaded:
    -	<ul class="minimalPadding">
    -	<li><span class="editorString">local</span> -
    -		copies the file to a local Allsky Website.
    -	<li><span class="editorString">ftps</span> -
    -		uses secure File Transfer Protocol (FTPs) to upload to a remote server.
    -	<li><span class="editorString">sftp</span> -
    -		uses SSH file transfer to upload to a remote server.
    -	<li><span class="editorString">ftp</span> -
    -		uses (insecure) File Transfer Protocol (FTP) to upload to a remote server.
    -		<blockquote class="warning">
    -			<span class="editorString">ftp</span> is unsecure;
    -			please use 
    -			<span class="editorString">ftps</span> or
    -			<span class="editorString">sftp</span> instead.
    -		</blockquote>
    -	<li><span class="editorString">scp</span> -
    -		uses secure cp (copy) to copy the file to a remote server.
    -	<li><span class="editorString">s3</span> -
    -		copies the file to an Amazon Web Services (AWS) server.
    -	<li><span class="editorString">gcs</span> -
    -		copies the file to a Google Cloud Storage (GCS) server.
    -	</ul>
    -	</td>
    -</tr>
    -
    -<tr><td class="note" colspan="3">image.jpg Settings</td></tr>
    -<tr><td><span class="shSetting">IMAGE_DIR</span></td> <td></td>
    -	<td>The remote directory where the current image should go.</td>
    -</tr>
    -<tr><td><span class="shSetting">WEB_IMAGE_DIR</span></td> <td></td>
    -	<td>The local directory where the current image should go.</td>
    -</tr>
    -
    -<tr><td class="note" colspan="3">Timelapse Settings</td></tr>
    -<tr><td><span class="shSetting">VIDEOS_DIR</span></td> <td></td>
    -	<td>The remote directory where the timelapse video should go.</td>
    -</tr>
    -<tr><td><span class="shSetting">VIDEOS_DESTINATION_NAME</span></td> <td></td>
    -	<td>Remote name of the timelapse video file.
    -	If not specified it's the same name as the file being uploaded.</td>
    -</tr>
    -<tr><td><span class="shSetting">WEB_VIDEOS_DIR</span></td> <td></td>
    -	<td>Location on the Pi to copy the timelapse video to.</td>
    -</tr>
    -
    -<tr><td class="note" colspan="3">Keogram Settings</td></tr>
    -<tr><td><span class="shSetting">KEOGRAM_DIR</span></td> <td></td>
    -	<td>The remote directory where the keogram image should go.</td>
    -</tr>
    -<tr><td><span class="shSetting">KEOGRAM_DESTINATION_NAME</span></td> <td></td>
    -	<td>Remote name of the keogram image file.
    -	If not specified it's the same name as the file being uploaded.</td>
    -</tr>
    -<tr><td><span class="shSetting">WEB_KEOGRAM_DIR</span></td> <td></td>
    -	<td>Location on the Pi to copy the keogram file to.</td>
    -</tr>
    -
    -<tr><td class="note" colspan="3">Startrails Settings</td></tr>
    -<tr><td><span class="shSetting">STARTRAILS_DIR</span></td> <td></td>
    -	<td>The remote directory where the startrails image should go.</td>
    -</tr>
    -<tr><td><span class="shSetting">STARTRAILS_DESTINATION_NAME</span></td> <td></td>
    -	<td>Remote name of the startrails file.
    -	If not specified it's the same name as the file being uploaded.</td>
    -</tr>
    -<tr><td><span class="shSetting">WEB_STARTRAILS_DIR</span></td> <td></td>
    -	<td>Location on the Pi to copy the startrails file to.</td>
    -</tr>
    -
    -<tr><td class="note" colspan="3">ftp, ftps, sftp, and scp Settings</td></tr>
    -<tr><td><span class="shSetting">REMOTE_HOST</span></td> <td></td>
    -	<td>Remote server DNS name or IP address.
    -	If you don't know it, ask your service provider.
    -	</td>
    -</tr>
    -
    -<tr><td class="note" colspan="3">ftp, ftps, and sftp Settings</td></tr>
    -<tr><td><span class="shSetting">REMOTE_USER</span></td> <td></td>
    -	<td>Your remote user name.</td>
    -</tr>
    -<tr><td><span class="shSetting">REMOTE_PASSWORD</span></td> <td></td>
    -	<td>Your ftp / ftps / sftp password.</td>
    -</tr>
    -<tr><td><span class="shSetting">REMOTE_PORT</span></td> <td></td>
    -	<td>An optional port number for <code>ftp</code> and <code>ftps</code>. Is rarely needed.</td>
    -</tr>
    -<tr><td><span class="shSetting">LFTP_COMMANDS</span></td> <td></td>
    -	<td>An optional colon-separated (;) list of commands needed by the <code>lftp</code> command.
    -	See <a allsky="true" href="/documentation/troubleshooting/uploads.html">this page</a>
    -	for example commands to enter into <span class="shSetting">LFTP_COMMANDS</span>.
    -	</td>
    -</tr>
    -
    -<tr><td class="note" colspan="3">scp Settings</td></tr>
    -<tr><td><span class="shSetting">REMOTE_USER</span></td> <td></td>
    -	<td>Your remote user name.
    -		This is the same setting as above.
    -	</td>
    -</tr>
    -<tr><td><span class="shSetting">SSH_KEY_FILE</span></td> <td></td>
    -	<td>Path to the SSH key file.
    -	<br>
    -	You need to set up SSH key authentication on your server.
    -	First, generate an SSH key on your Pi:
    -<pre>ssh-keygen -t rsa</pre>
    -	When prompted, leave the default filename, and use an empty passphrase.
    -	Then, copy the generated key to your server:
    -<pre>ssh-copy-id remote_username@server_ip_address</pre>
    -	The private SSH key will be stored in
    -	<span class="fileName">~/.ssh</span> (default filename is <span class="fileName">id_rsa</span>)
    -	</td>
    -</tr>
    -
    -<tr><td class="note" colspan="3">s3 Settings</td></tr>
    -<tr><td colspan="3">
    -	You need to install the AWS Command-Line Interface (CLI):
    -<pre>
    -sudo apt-get install python3-pip
    -pip3 install awscli --upgrade --user
    -export PATH=/home/pi/.local/bin:$PATH
    -aws configure
    -</pre>
    -	When prompted, enter a valid access key ID, Secret Access Key, and Default region name,
    -	for example, (e.g. "us-west-2").
    -	Set the Default output format to "json" when prompted.
    -	</td>
    -</tr>
    -<tr><td><span class="shSetting">AWS_CLI_DIR</span></td>
    -	<td><span class="editorString">/home/pi/.local/bin</span></td>
    -	<td>Directory on the Pi where the AWS tools are installed.
    -	<br>If you used a different PATH setting above, change this setting to match it.
    -	</td>
    -</tr>
    -<tr><td><span class="shSetting">S3_BUCKET</span></td>
    -	<td><span class="editorString">allskybucket</span></td>
    -	<td>Name of S3 Bucket where the files will be uploaded
    -	(must be in Default region specified above).
    -	You may want to turn off or limit bucket versioning to avoid consuming lots of
    -	space with multiple versions of the "image.jpg" files.
    -	</td>
    -</tr>
    -<tr><td><span class="shSetting">S3_ACL</span></td>
    -	<td><span class="editorString">private</span></td>
    -	<td>S3 Access Control List (ACL).
    -	If you want to serve your uploaded files vis http(s),
    -	change this to <span class="editorString">public-read</span>.
    -	You will need to ensure the S3 bucket policy is configured to allow public access to
    -	objects with a <span class="editorString">public-read</span> ACL.
    -	<br>You may need to set a CORS policy in S3 if the files are to be accessed by
    -	Javascript from a different domain.
    -	</td>
    -</tr>
    -
    -<tr><td class="note" colspan="3">GCS Settings</td></tr>
    -<tr><td colspan="3">
    -	You need to install the <code>gsutil</code> command which is part of the Google Cloud SDK.
    -	See installation instructions
    -	<a href="https://cloud.google.com/storage/docs/gsutil_install">here</a>.
    -	<br>
    -	NOTE: The <code>gsutil</code> command must be installed somewhere in the standard $PATH,
    -	usually in <span class="fileName">/usr/bin</span>.
    -	Make sure you authenticate the cli tool with the correct user as well.
    -<tr><td><span class="shSetting">GCS_BUCKET</span></td>
    -	<td><span class="editorString">allskybucket</span></td>
    -	<td>Name of S3 Bucket where the files will be uploaded.</td>
    -</tr>
    -<tr><td><span class="shSetting">GCS_ACL</span></td>
    -	<td><span class="editorString">private</span></td>
    -	<td>GCS Access Control List.
    -	You can use any one of the predefined ACL rules found
    -	<a href="https://cloud.google.com/storage/docs/access-control/lists#predefined-acl">here</a>.
    -	To access files over https, set this to <span class="editorString">publicRead</span>.
    -	</td>
    -</tr>
    -</tbody>
    -</table>
    -
    -<hr class="separatorSmall">
    -<h3 id="upload-combinations">Settings for various combinations</h3>
    +<h2>Configure Website settings</h2>
     <ol>
    -<li>If you have the Allsky Website <b>only</b> on your Pi, use these settings:
    -<pre>
    -PROTOCOL="local"
    -IMAGE_DIR="${ALLSKY_WEBSITE}"
    -VIDEOS_DIR="${IMAGE_DIR}/videos"
    -KEOGRAM_DIR="${IMAGE_DIR}/keograms"
    -STARTRAILS_DIR="${IMAGE_DIR}/startrails"
    -</pre>
    -
    -<li>
    -	If you have an Allsky Website <b>only</b> on a remote server, use these settings.
    -	For the sake of this example, assume your top-level directory on the server is
    -	<span class="fileName">/allsky</span>:
    -<pre>
    -PROTOCOL="sftp"		<span class="shellComment"># or another PROTOCOL</span>
    -IMAGE_DIR="/allsky"
    -VIDEOS_DIR="/allsky/videos"
    -KEOGRAM_DIR="/allsky/keogram"
    -STARTRAILS_DIR="/allsky/startrails"
    -</pre>
    -
    -<li>
    -	If you have an Allsky Website on your Pi <b>AND</b> on a remote server, use these settings.
    -	For the sake of this example, assume your top-level directory on the server is
    -	<span class="fileName">/allsky</span>:
    -<pre>
    -PROTOCOL="sftp"		<span class="shellComment"># or another PROTOCOL</span>
    -IMAGE_DIR="/allsky"
    -WEB_IMAGE_DIR=""
    -VIDEOS_DIR="/allsky/videos"
    -WEB_VIDEOS_DIR="${ALLSKY_WEBSITE}/videos"
    -KEOGRAM_DIR="/allsky/keogram"
    -WEB_KEOGRAM_DIR="${ALLSKY_WEBSITE}/keograms"
    -STARTRAILS_DIR="/allsky/startrails"
    -WEB_STARTRAILS_DIR="${ALLSKY_WEBSITE}/startrails"
    -</pre>
    +	<li>In the WebUI, click on the <span class="WebUILink">Editor</span> link.
    +	<li>In the drop-down at the bottom of the page, select
    +		one of the following, depending on which Website you want to configure:
    +		<ul>
    +			<li><span class="dropdown">configuration.json (local Allsky Website)</span> or
    +			<li><span class="dropdown">remote_configuration.json (remote Allsky Website)</span>
    +				(only if you installed a remote Website)
    +			<li>You will then see something like this:
    +				<img allsky="true" src="EditorPageNotEnabled.png" class="imgBorder imgCenter"
    +					title="Typical Editor page" alt="Editor" loading="lazy">
    +			<li>Information on the color scheme used by the Editor in the screenshot above is
    +				<a allsky="true" external="true" href="EditorColors.html"
    +					target="_blank" title="Opens in new tab">here </a>.
    +		</ul>
    +	<li>Ignore any message about the Website not being enabled - 
    +		you will do that in the next step.
    +		<ul>
    +			<li id="configuration.json">
    +				The settings in both files are identical although their values may differ.
    +				The files are split into two sections:
    +				<ol type="i" class="minimalPadding topPadding">
    +					<li><span class="settings editorSetting">"config"</span> -
    +						settings for the liveview image and constellation overlay.</li>
    +					<li><span class="settings editorSetting">"homePage"</span> -
    +						settings to change the look and feel
    +						of the Website's home page including the icons on the left side,
    +						the information popout on the right side,
    +						an optional background image, etc.</li>
    +				</ol>
    +				See the two sections at the bottom of this page for information
    +				on each section.
    +				</li>
    +			<li>Each setting has a <strong>name</strong> and <strong>value</strong>,
    +				separated by a colon (<code>:</code>).
    +				Setting <strong>names</strong> in the file look like
    +				<span class="editorSetting">this</span>
    +				and should generally NOT be changed unless, for example,
    +				you are adding a new icon on the left side of the screen.
    +				<br>Setting names MUST always be enclosed in double quotes.
    +				</li>
    +			<li>You should change setting <strong>values</strong> as desired -
    +				they have different colors depending on their types,
    +				as described <a allsky="true" external="true" href="EditorColors.html"
    +					target="_blank" title="Opens in new tab">here</a>.
    +				<blockquote class="warning">
    +				Make sure all <span class="editorString">XX_NEED_TO_UPDATE_XX</span>
    +				values are updated.
    +				</blockquote>
    +				</li>
    +			<li>Some settings like the
    +				<span class="editorSetting">latitude</span>
    +				are also in the WebUI and should already be filled in.
    +				Those settings should only be changed in the WebUI,
    +				not in the file itself.
    +				The WebUI will ensure any changes are propogated to the
    +				appropriate file(s).
    +				</li>
    +			<li><blockquote>
    +					<strong>Tip:</strong>
    +					You can add comments to yourself by adding a new
    +					setting name and value, e.g., 
    +					<br><span class="editorSpecial">
    +					<span class="editorSetting">"myComment1"</span> :
    +					<span class="editorString">"Need to check the next setting"</span>,
    +					</span>
    +					<br>Be sure all setting names are unique.
    +				</blockquote>
    +		</ul>
    +	</li>
     </ol>
    -<hr class="separatorSmall">
    -
     
    -<h3>Amazon Lightsail</h3>
    -If you are using SSH Key with Amazon's Lightsail,
    -copy the <span class="fileName">ssh-key.pem</span> file to your Pi,
    -for example, in <span class="fileName">~</span>,
    -then execute
    -<pre>chmod 400 ~/ssh-key.pem</pre>
    -and set:
    -<pre>
    -PROTOCOL="sftp"
    -REMOTE_HOST="remote host name"
    -REMOTE_USER="remote user name"
    -REMOTE_PASSWORD="n/a"
    -LFTP_COMMANDS="set sftp:connect-program 'ssh -a -x -i /home/pi/ssh-key.pem'"
    -</pre>
    -<hr class="separator">
    -</details>
    -<!-- ========================================================== -->
    -
    -<h2>configuration.json files</h2>
    -<p>
    -This section describes the settings in the 
    -<span class="dropdown">configuration.json (local Allsky Website)</span> and
    -<span class="dropdown">remote_configuration.json (remote Allsky Website)</span> files.
    -</p>
    -<details><summary></summary>
    -<p id="configuration.json">
    -The settings in both files are identical (although their values may differ)
    -and they are split into two sections:
    +<h2>Enable the Website</h2>
     <ol>
    -<li><span class="settings editorSetting">"config"</span> -
    -	settings for the liveview image and constellation overlay.
    -<li><span class="settings editorSetting">"homePage"</span> -
    -	settings to change the look and feel
    -	of the Website's home page including the icons on the left side,
    -	the information popout on the right side, an optional background image, etc.
    +	<li>On the WebUI's <span class="WebUILink">Allsky Settings</span> page display the
    +		<span class="settingsHeader">Website and Remote Server Settings</span> section.
    +		</li>
    +	<li>Enable the Website in either the
    +		<span class="subSettingsHeader">Local Website Settings</span>
    +		or
    +		<span class="subSettingsHeader">Remote Website Settings</span> subsection,
    +		depending on which Website you are configuring.
    +		</li>
    +	<li>Change the other settings in that subsection as needed.
    +		Remote Websites need to know the server name, login, etc.
    +		</li>
     </ol>
    -</p>
    +
     <p>
    -Any setting whose value is <b>XX_need_to_update_XX</b>
    -needs to be updated prior to using the Website.
    +The subsections below describe the settings in the json files,
    +their default values, and a description.
     </p>
     
     
    @@ -388,19 +146,20 @@ <h3><span class="settings editorSetting">config</span> settings</h3>
     <details><summary></summary>
     <div class="legend">
     <span class="legendHeader">Legend:</span>
    -<ul class="minimalPadding">
    +<ul class="minimalPadding topPadding">
     <li>Values for setting names with "<span class="sentToMapServer"></span>" after them
    -	are sent to the <a allsky="true" href="/documentation/allskyMap/index.html">Allsky Map</a>
    -	server if you camera is on the map.
    +	are sent to the
    +	<a allsky="true" external="true" href="/documentation/allskyMap/index.html">Allsky Map</a>
    +	server if your camera is on the map.
     <li>Setting names with "<span class="vsOverlay"></span>" after them
     	impact the virtual sky overlay.
     	A complete list of virtual-sky based options is
    -	<a href="https://slowe.github.io/VirtualSky/#options">here</a>.
    +	<a external="true" href="https://slowe.github.io/VirtualSky/#options">here</a>.
     <li>Values marked with <span class="autoSet"></span> are automatically set during
     	installation based on your WebUI settings and your Pi model, but can be overridden.
     </ul>
     <blockquote>
    -It's important to update your settings in the WebUI <b>before</b> installing the
    +It's important to update your settings in the WebUI <b>before</b> configuring the
     Allsky Website so you only have to update them once.
     </blockquote>
     </div>
    @@ -425,7 +184,7 @@ <h3><span class="settings editorSetting">config</span> settings</h3>
     		<span class="editorString">image.jpg</span> (remote)
     	</td>
     	<td>The image uploaded from your allsky camera.
    -		<br>Normally should not be changed.
    +		<br><strong>Normally should not be changed.</strong>
     	</td>
     </tr>
     <tr>
    @@ -512,7 +271,7 @@ <h3><span class="settings editorSetting">config</span> settings</h3>
     	<td><span class="editorSetting">showOverlayAtStartup</span></td>
     	<td><span class="editorBool">false</span></td>
     	<td>Set to <span class="editorBool">true</span>
    -		to have the overlay displayed when the page is loaded.
    +		to have the constellation overlay displayed when the page is loaded.
     	</td>
     </tr>
     <tr>
    @@ -528,14 +287,14 @@ <h3><span class="settings editorSetting">config</span> settings</h3>
     <tr>
     	<td><span class="editorSetting">overlayOffsetLeft</span> <span class="vsOverlay"></span></td>
     	<td><span class="editorNum">0</span></td>
    -	<td>Horizontal adjustment of the overlay in pixels.
    +	<td>Enter a positive number of pixels to move the constellation overlay to the right.
     		Use negative numbers to move left.
     	</td>
     </tr>
     <tr>
     	<td><span class="editorSetting">overlayOffsetTop</span> <span class="vsOverlay"></span></td>
     	<td><span class="editorNum">0</span></td>
    -	<td>Vertical adjustment of the overlay in pixels.
    +	<td>Enter a positive number of pixels to move the constellation overlay down.
     		Use negative numbers to move up.
     	</td>
     </tr>
    @@ -549,7 +308,7 @@ <h3><span class="settings editorSetting">config</span> settings</h3>
     <tr>
     	<td><span class="editorSetting">imageWidth</span> <span class="vsOverlay"></span></td>
     	<td><span class="editorNum">900</span></td>
    -	<td>Maximum width of the captured image in pixels.
    +	<td>Width of the captured image in pixels.
     		The image height will be calculated automatically to keep the aspect ratio constant.
     	</td>
     </tr>
    @@ -574,12 +333,12 @@ <h3><span class="settings editorSetting">config</span> settings</h3>
     <tr>
     	<td><span class="editorSetting">meridian</span> <span class="vsOverlay"></span></td>
     	<td><span class="editorBool">false</span></td>
    -	<td>Displays the meridian line.</td>
    +	<td>Display the meridian line?</td>
     </tr>
     <tr>
     	<td><span class="editorSetting">ecliptic</span> <span class="vsOverlay"></span></td>
     	<td><span class="editorBool">false</span></td>
    -	<td>Displays the ecliptic line.</td>
    +	<td>Display the ecliptic line?</td>
     </tr>
     <tr>
     	<td><span class="editorSetting">fontsize</span> <span class="vsOverlay"></span></td>
    @@ -602,7 +361,7 @@ <h3><span class="settings editorSetting">config</span> settings</h3>
     <tr>
     	<td><span class="editorSetting">showstarlabels</span> <span class="vsOverlay"></span></td>
     	<td><span class="editorBool">true</span></td>
    -	<td>Display the star names.</td>
    +	<td>Display the star names?</td>
     </tr>
     <tr>
     	<td><span class="editorSetting">projection</span> <span class="vsOverlay"></span></td>
    @@ -666,7 +425,7 @@ <h3><span class="settings editorSetting">config</span> settings</h3>
     	<td><span class="editorBool">true</span></td>
     	<td>Allow keyboard controls?
     		If set to <span class="editorBool">true</span>,
    -		type "?" when over the image for a list of keyboard commands.
    +		type <code>?</code> when over the image for a list of keyboard commands.
     	</td>
     </tr>
     <tr>
    @@ -731,11 +490,6 @@ <h3><span class="settings editorSetting">config</span> settings</h3>
     	<td></td>
     	<td><b>Do not change</b>.</td>
     </tr>
    -<tr>
    -	<td><span class="editorSetting">AllskyWebsiteVersion</span></td>
    -	<td></td>
    -	<td><b>Do not change</b>.</td>
    -</tr>
     </tbody>
     </table>
     <hr class="separator">
    @@ -759,16 +513,6 @@ <h3><span class="settings editorSetting">homePage</span> settings</h3>
     	<td></td>
     	<td>This line is describes what this section is for and can be deleted if desired.</td>
     </tr>
    -<tr>
    -	<td><span class="editorSetting">onPi</span></td>
    -	<td><span class="editorBool">true</span> (local) or
    -		<br><span class="editorBool">false</span> (remote)
    -	</td>
    -	<td>Automatically set to indicate if the Website is running locally on the Pi
    -		or on a remote server.
    -		Should normally not need to change.
    -	</td>
    -</tr>
     <tr>
     	<td><span class="editorSetting">title</span></td>
     	<td>XX_NEED_TO_UPDATE_XX</td>
    @@ -787,9 +531,9 @@ <h3><span class="settings editorSetting">homePage</span> settings</h3>
     <tr>
     	<td><span class="editorSetting">backgroundImage</span></td>
     	<td></td>
    -	<td>An optional background image for the home page.
    +	<td>An optional background image for the Website home page.
     		Sub-settings:
    -		<ul class="minimalPadding">
    +		<ul class="minimalPadding topPadding">
     		<li><span class="editorSetting">url</span> is
     			the location of the image (a URL or file name).
     		<li><span class="editorSetting">style</span> is
    @@ -808,8 +552,8 @@ <h3><span class="settings editorSetting">homePage</span> settings</h3>
     		Can be a URL or a file name.
     		<blockquote>
     		If this is a file on your Pi or remote server,
    -		it's suggested to put it in the Website's <span class="fileName">myImages</span>
    -		directory which is backed up and restored when upgrading the Allsky Website.
    +		put it in the Website's <span class="fileName">myFiles</span>
    +		directory which is restored when upgrading the Allsky Website.
     		</blockquote>
     	</td>
     </tr>
    @@ -842,11 +586,11 @@ <h3><span class="settings editorSetting">homePage</span> settings</h3>
     		<br>
     		<span style="color: red;">Click
     		<a href="https://google.com" title="this is a title">here to go to Google.</a></span>
    -		<ul class="minimalPadding">
    +		<ul class="minimalPadding topPadding">
     		<li><span class="editorSetting">prelink</span> -
     			Text before the link (e.g., "<span style="color: red">Click</span>").
     		<li><span class="editorSetting">message</span> -
    -			What the link says (<span style="color: blue">here to go to Google</span>).
    +			What the link says (<span style="color: blue">"here to go to Google"</span>).
     		<li><span class="editorSetting">url</span> -
     			The location the user is taken to when clicking on the link ("https://google.com").
     		<li><span class="editorSetting">title</span> -
    @@ -884,26 +628,45 @@ <h3><span class="settings editorSetting">homePage</span> settings</h3>
     	<td>Location of the "favorite icon" which is displayed in the browser title bar.
     		<blockquote>
     		If this is a file on your Pi or remote server,
    -		it's suggested to put it in the <span class="fileName">myImages</span> directory which is
    -		backed up and restored when upgrading the Allsky Website.
    +		put it in the <span class="fileName">myFiles</span> directory which is
    +		restored when upgrading the Allsky Website.
     		</blockquote>
     	</td>
     </tr>
     <tr>
    -	<td><span class="editorSetting">leftSidebar</span></td>
    +	<td><span class="editorSetting">thumbnailsizex</span></td>
    +	<td><span class="editorNumber">100</span></td>
    +	<td>Horizontal size of keogram, startrails, and timelapse thumbnails.
    +	</td>
    +</tr>
    +<tr>
    +	<td><span class="editorSetting">thumbnailsizey</span></td>
    +	<td><span class="editorNumber">75</span></td>
    +	<td>Vertical size of keogram, startrails, and timelapse thumbnails.
    +	</td>
    +</tr>
    +<tr>
    +	<td><span class="editorSetting">thumbnailsortorder</span></td>
    +	<td><span class="editorString">ascending</span></td>
    +	<td>How the keogram, startrails, and timelapse thumbnails should be sorted.
    +	<span class="editorString">ascending</span> means newest to oldest.
    +	<span class="editorString">descending</span> means oldest to newest.
    +	</td>
    +</tr>
    +<tr>
    +	<td><a id="leftSidebar"><span class="editorSetting">leftSidebar</span></td>
     	<td></td>
     	<td>Settings that modify the left sidebar's icons.
     		Each icon has the following settings:
    -		<ul class="minimalPadding">
    +		<ul class="minimalPadding topPadding">
     		<li><span class="editorSetting">display</span> -
     			<span class="editorBool">true</span> to display the icon,
     			<span class="editorBool">false</span> to hide it.
     			<blockquote>
    -			The overlay toggle icon's display setting is
    -			<span class="editorBool">false</span> by default.
    -			If you plan to allow the overlay to be displayed,
    -			modify its size and position so it mostly matches the sky,
    -			then set this field to
    +			This setting is <span class="editorBool">false</span> by default
    +			for the constellation overlay icon.
    +			If you plan to allow the constellation overlay to be displayed,
    +			modify its size and position so it mostly matches the sky, then set this field to
     			<span class="editorBool">true</span>.
     			</blockquote>
     		<li><span class="editorSetting">url</span> -
    @@ -911,9 +674,10 @@ <h3><span class="settings editorSetting">homePage</span> settings</h3>
     		<li><span class="editorSetting">title</span> -
     			Text displayed when hovering over the icon.
     		<li><span class="editorSetting">icon</span> -
    -			A list of <a href="https://fontawesome.com/v5/search?o=r&m=free">Font Awesome</a>
    -			version 5.14.5 classes that determine which icon is used.
    +			A list of <a href="https://fontawesome.com/search?m=free&o=r">Font Awesome</a>
    +			version 6.2.1 classes that determine which icon is used.
     			Note that not all icons work.
    +			Look at another sidebar entry for an example.
     		<li><span class="editorSetting">other</span> -
     			Text to add to the link <b>in place of a url</b>.
     			Typically used to take an action, like display a popup, when the icon is clicked.
    @@ -928,7 +692,7 @@ <h3><span class="settings editorSetting">homePage</span> settings</h3>
     	<td><span class="editorSetting">leftSidebarStyle</span></td>
     	<td></td>
     	<td>CSS for the left sidebar itself, not the icons in it.
    -		To change the sidebar background yellow:
    +		To change the sidebar background to yellow:
     			<span class="editorString">background-color: yellow;</span>.
     	</td>
     </tr>
    @@ -938,16 +702,17 @@ <h3><span class="settings editorSetting">homePage</span> settings</h3>
     	<td>Settings that modify the information that appears in the popout on the right side.
     		Each entry contains an icon, name of the field, and the field's value,
     		and has the following settings:
    -		<ul class="minimalPadding">
    +		<ul class="minimalPadding topPadding">
     		<li><span class="editorSetting">display</span> -
     			<span class="editorBool">true</span> to display the entry,
     			<span class="editorBool">false</span> to hide it.
    -			<blockquote>
    +			<blockquote class="warning">
     			The <span class="editorString">Allsky Settings</span>'s
     			<span class="editorSetting">display</span> setting
    -			should NOT be changed in the <span class="WebUIPage">Editor</span> page;
    -			instead, it should be changed in the WebUI's
    -			<span class="WebUIPage">Allsky Settings</span> page.
    +			should NOT be changed in the <span class="WebUILink">Editor</span> page;
    +			instead, it should be changed using the
    +			<span class="WebUISetting">Display Settings</span> setting
    +			in the WebUI's <span class="WebUILink">Allsky Settings</span> page.
     			</blockquote>
     		<li><span class="editorSetting">label</span> -
     			The name of the field for the entry.
    @@ -955,7 +720,7 @@ <h3><span class="settings editorSetting">homePage</span> settings</h3>
     			The same as with <span class="editorSetting">leftSidebar</span> above.
     		<li><span class="editorSetting">variable</span> - A setting name from the
     			<span class="editorSetting">config</span>
    -			section whose value should be displayed.
    +			section of the configuration file whose value should be displayed.
     			For example, if you set the <span class="editorSetting">owner</span>
     			to <span class="editorString">Eric</span>,
     			entering <span class="editorSetting">owner</span> for this setting will display
    @@ -977,11 +742,8 @@ <h3><span class="settings editorSetting">homePage</span> settings</h3>
     </table>
     </details>
     
    -</details>
    -
     </div><!-- Layout-main -->
     </div><!-- Layout -->
     </body>
     </html>
     <script> includeHTML(); </script>
    -
    diff --git a/html/documentation/sidebar.html b/html/documentation/sidebar.html
    index 336d74838..555dbf601 100644
    --- a/html/documentation/sidebar.html
    +++ b/html/documentation/sidebar.html
    @@ -8,11 +8,14 @@
     	<ol type="i">
     	<li><a allsky="true" href="/documentation/basics/Linux.html">Linux</a>
     	<li><a allsky="true" href="/documentation/basics/Pi.html">Raspberry Pi</a>
    +	<li><a allsky="true" href="/documentation/basics/Allsky.html">How Allsky works</a>
     	</ol>
     <li>Installing / Upgrading:
     	<ol type="i">
    +	<li><a allsky="true" href="/documentation/explanations/imageSDcard.html">Image an SD card</a>
     	<li><a allsky="true" href="/documentation/installations/Allsky.html">Allsky</a>
     	<li><a allsky="true" href="/documentation/installations/AllskyWebsite.html">Allsky Website</a>
    +	<li><a allsky="true" href="/documentation/installations/RemoteServer.html">Remote server</a>
     	</ol>
     <li>Settings:
     	<ol type="i">
    @@ -28,14 +31,17 @@
     	<li><a allsky="true" href="/documentation/explanations/darkFrames.html">Dark frames</a>
     	<li><a allsky="true" href="/documentation/explanations/keograms.html">Keograms</a>
     	<li><a allsky="true" href="/documentation/explanations/startrails.html">Startrails</a>
    -	<li><a allsky="true" href="/documentation/explanations/SystemPageAdditions.html">WEBUI_DATA_FILES</a>
    +	<li><a allsky="true" href="/documentation/explanations/timelapses.html">Timelapses</a>
    +	<li><a allsky="true" href="/documentation/explanations/SystemPageAdditions.html">System Page Additions</a>
     	<li><a allsky="true" href="/documentation/explanations/angleSunriseSunset.html">Angle &amp; sunrise/sunset</a>
     	<li><a allsky="true" href="/documentation/explanations/exposureGainSaturation.html">Exposure, gain, stretch, saturation</a>
     	<li><a allsky="true" href="/documentation/explanations/SSL.html">Using / SSL - Secure Socket Layers</a>
    +	<li><a allsky="true" href="/documentation/explanations/SAMBA.html">Copy files to / from a Pi</a>
    +	<li><a allsky="true" href="/documentation/explanations/requestCameraSupport.html">Request support for new camera</a>
    +	<li><a allsky="true" href="/documentation/explanations/serverLocationToURL.html">Map server locations to URLs</a>
     	<li><a allsky="true" href="/documentation/miscellaneous/cleaningWebsite.html">Remove old files from a Website</a>
    +	<li><a allsky="true" href="/documentation/miscellaneous/AllskyMap.html">Put your camera on Allsky Map</a>
     	</ol>
    -<li><a allsky="true" href="/documentation/miscellaneous/AllskyMap.html">Put your camera on Allsky Map</a>
    -<li><a allsky="true" href="/documentation/miscellaneous/FAQ.html">FAQ</a>
     <li>Troubleshooting:
     	<ol type="i">
     	<li><a allsky="true" href="/documentation/troubleshooting/reportingIssues.html"><b>Reporting Issues</b></a>
    @@ -50,6 +56,7 @@
     	<li><a allsky="true" href="/documentation/troubleshooting/hardware.html">Other Hardware</a>
     	<li><a allsky="true" href="/documentation/troubleshooting/other.html">Other</a>
     	</ol>
    +<li><a allsky="true" href="/documentation/miscellaneous/FAQ.html">FAQ</a>
     <li><a allsky="true" href="/documentation/miscellaneous/pickingHardware.html">Picking a Pi, allsky camera, and lens</a>
     <li><a allsky="true" href="/documentation/knownIssues.html">Known issues and limitations</a>
     <li><a allsky="true" href="/documentation/changeLog.html">Version change log</a>
    diff --git a/html/documentation/troubleshooting/AllskyWebsite.html b/html/documentation/troubleshooting/AllskyWebsite.html
    index f2791fec3..1fc505942 100644
    --- a/html/documentation/troubleshooting/AllskyWebsite.html
    +++ b/html/documentation/troubleshooting/AllskyWebsite.html
    @@ -32,27 +32,29 @@
     <h2>WARNING: sunset is XX days old. Check Allsky log file if 'postData.sh' has been running...</h2>
     <p>
     If you have a local or remote Allsky Website, at the end of every night
    -Allsky calls <code>postData.sh</code> to upload a small file called
    +Allsky calls <code>postData.sh</code> to upload a file called
     <span class="fileName">data.json</span> to the Website(s).
     This file contains the sunset time and other information needed by the Website.
     </p>
    -If you are seeing the message above when you go to the Allsky Website in a browser,
    +<p>
    +If you are seeing the message above when you go to the Allsky Website,
     try running the command manually:
    -<pre>cd ~/allsky/scripts
    -./postData.sh</pre>
    -
    +<pre>postData.sh</pre>
    +</p>
    +<p>
     There will be no output on success.
     If you see an error message and it's not obvious what it means, run:
    -<pre>./postData.sh --debug</pre>
    +<pre>postData.sh --debug</pre>
     which will produce many lines of debugging output which may help you determine
     where the problem is.
    -At a minimum, it will provide something to attach to any trouble ticket you enter.
    +At a minimum, it will provide something to attach to any GitHub Issue you enter.
    +</p>
     
     <h2>ERROR: 'sunset' not defined in 'data.json' ...</h2>
     <p>
     Follow the instructions in the error message.
     If this message continues to appear,
    -open a new <a href="https://github.com/AllskyTeam/allsky/issues">Issue</a>.
    +open a new <a external="true" href="https://github.com/AllskyTeam/allsky/issues">Issue</a>.
     </p>
     
     
    @@ -67,9 +69,8 @@ <h2>After changing the <span class="WebUISetting">Filename</span> in the WebUI y
     <span class="editorSetting">fileName</span> value in
     the <span class="fileName">configuration.json</span> file (local and/or remote)
     via the WebUI's <span class="WebUILink">Editor</span> page.
    -If that solved your problem, please
    -open an <a href="https://github.com/AllskyTeam/allsky/issues">Issue</a> per
    -<a allsky="true" href="/documentation/troubleshooting/reportingIssues.html">these instructions</a>.
    +If that solved your problem, please follow
    +<a allsky="true" href="reportingIssues.html">these instructions</a> to report a problem.
     </p>
     
     
    @@ -91,41 +92,45 @@ <h2>Can't access the Website from a browser</h2>
     for example <code>http://allsky/allsky</code>, then try using the Pi's IP address,
     for example <code>http://192.168.0.21/allsky</code>.
     </p>
    +<blockquote>
    +Remember that to access an Allsky Website on your Pi you need to enter
    +<code>http://allsky/allsky</code>; entering
    +<code>http://allsky</code> takes you to the WebUI.
    +</blockquote>
     
     <h2>Archived video files have no thumbnail</h2>
     <p>
     If you click on the
    -<i class="fa fa-lg fa-fw fa-play-circle"></i> "Archived Timelapses" icon on the left side of the
    +<i class="fa fa-lg fa-fw fa-play-circle" style="color: #888"></i> (Archived Timelapses)
    +icon on the left side of the
     Website and one or more videos say "No Thumbnail", see below.
     </p>
    -<div style="margin-left: 5em">
    -<h3>On your Pi</h3>
    +<h4>On your Pi</h4>
    +<p>
     This shouldn't happen on the Pi - it should automatically create the thumbnails when
     you go to the archived timelapses directory.
     Look in <span class="fileName">/var/log/lighttpd/error.log</span> for clues to the problem.
    +</p>
     
    -<h3>On a remote server</h3>
    +<h4>On a remote server</h4>
     <p>
     For security reasons, many hosting solutions disable the commands needed to create
     video thumbnails.
     </p>
     <p>
    -To overcome this problem, edit the <span class="fileName">config.sh</span> file via the WebUI
    -and set <span class="shSetting">TIMELAPSE_UPLOAD_THUMBNAIL</span> to "true".
    -If you are uploading mini-timelapse, also set
    -<span class="shSetting">TIMELAPSE_MINI_UPLOAD_THUMBNAIL</span> to "true".
    +To overcome this problem
    +enable the <span class="WebUISetting">Upload Thumbnail</span> setting(s)
    +for the Daily and/or Mini Timelapse.
     <blockquote>
     Making this change will only create thumbnails for all <em>future</em> videos.
    -To create the currently missing thumbnails, do the following for each missing date,
    +To create the currently missing thumbnails, do the following for each missing thumbnail,
     replacing <code>YYYYMMDD</code> with the date in <span class="fileName">~/allsky/images</span>
     <pre>
    -cd ~/allsky/scripts
    -./generateForDay.sh --thumbnail-only --upload --timelapse YYYYMMDD
    +generateForDay.sh --thumbnail-only --upload --timelapse YYYYMMDD
     </pre>
     This will upload the thumbnail for the specified date.
     </blockquote>
     </p>
    -</div>
     
     </div><!-- Layout-main -->
     </div><!-- Layout -->
    diff --git a/html/documentation/troubleshooting/RPiCameras.html b/html/documentation/troubleshooting/RPiCameras.html
    index f9b111f68..95dab1017 100644
    --- a/html/documentation/troubleshooting/RPiCameras.html
    +++ b/html/documentation/troubleshooting/RPiCameras.html
    @@ -25,14 +25,14 @@
     <div class="Layout-main markdown-body" id="mainContents">
     
     <p>
    -This page describes known issues with the RPi cameras (HQ and Module 3).
    +This page describes known issues with RPi and RPi-compatible cameras.
     </p>
     
     <h2>RPi camera not found or not working</h2>
     <details><summary></summary>
     <p>
     A "camera not found" message can be due many different things.
    -Here are some steps to try.
    +Here are some steps to try:
     </p>
     <ol>
     <li>If this is a new RPi camera, make sure the ribbon cable is installed correctly.
    @@ -46,12 +46,13 @@ <h2>RPi camera not found or not working</h2>
     	If you get an error like this:
     	<pre>terminate called after throwing an instance of 'std::runtime_error'
       	what():  failed to import fd 22</pre>
    -	then run <code>sudo raspi-config</code>, navigate to
    +	run <code>sudo raspi-config</code>, navigate to
     	<code>Advanced Options</code> and enable <code>Glamor</code> graphic acceleration.
     	Then reboot your Pi.
     	Now, try <code>libcamera-hello</code> again.
    -	You should get a <b>Hello world!</b> message in a new window.
    -<li>Check for under voltage (Bullseye only)
    +	You should get a <code>Hello world!</code> message in a new window.
    +	<br><strong>This is not needed on Bookworm.</strong>
    +<li>Check for under voltage (Bullseye and Bookworm only)
     	<br>
     	Execute:
     	<pre>libcamera-hello</pre>
    @@ -60,16 +61,14 @@ <h2>RPi camera not found or not working</h2>
     	your Pi may be under voltaged.
     	<ul>
     	<li>If you have another power supply try it.
    -	<li>Using <code>sudo</code>, add
    -		<pre>over_voltage=4</pre>
    +	<li>Using <code>sudo</code>, add <code>over_voltage=4</code>
     		to <span class="fileName">/boot/config.txt</span> then reboot your Pi.
    -		<br>
    -		If you are still having the problem, add
    -		<pre>arm_freq=700</pre>
    +		If you are still having the problem, add <code>arm_freq=700</code>
     		to <span class="fileName">/boot/config.txt</span> then reboot your Pi.
     		Note that this decreases the speed of your Pi so should be a last resort.
     		<br>See
    -		<a href="https://github.com/raspberrypi/libcamera-apps/issues/246">more information</a>.
    +		<a external="true"
    +			href="https://github.com/raspberrypi/libcamera-apps/issues/246">more information</a>.
     	</ul>
     </ol>
     </details>
    @@ -86,30 +85,6 @@ <h2>RPi HD camera stops taking pictures</h2>
     </pre>
     </details>
     
    -<h2>RPi settings are no longer good</h2>
    -<details><summary></summary>
    -<p>
    -On October 31, 2021 a new version of the Raspberry Pi operating system,
    -called "Bullseye" was released (the prior version was called "Buster").
    -One of the changes in Bullseye was to replace the software that controls RPi cameras.
    -If you downloaded the Raspberry Pi operating system on October 31 or after, you have Bullseye.
    -<blockquote>
    -It is <b>strongly</b> recommended that you use the Bullseye operating system if
    -you have an RPi camera.
    -All new features are being added to Bullseye only,
    -so users of Buster won't have new features like sensor temperature.
    -</blockquote>
    -</p>
    -<p>
    -Some of the Allsky settings need to be changed when going from Buster to Bullseye.
    -For example, the <span class="WebUISetting">Contrast</span> setting on Buster goes from
    -<span class="WebUIValue">-100</span> to <span class="WebUIValue">100</span>
    -but on Bullseye it starts at
    -<span class="WebUIValue">0</span>.
    -Hovering over a value will display the minimum, maximum, and default values for that setting.
    -</p>
    -</details>
    -
     
     <h2>RETCODE=137</h2>
     <details><summary></summary>
    @@ -126,14 +101,15 @@ <h2>RETCODE=137</h2>
     An exit code of <b>137</b> usually means the command was forcefully killed by an outside source,
     often the Linux kernel.
     When this happens to an Allsky command, the log file will usually contain a message
    -with <b>Killed</b> in it, and often with <b>RETCODE=137</b> in it.
    +with <code>Killed</code> in it, and often with <code>RETCODE=137</code> in it.
     </p>
     <p>
     If you are using a RPi camera and get a notification message saying
     <code>ERROR  See /var/log/allsky.log for details</code>
     and the log file contains entries similar to these:
     <pre>
    -Jan 20 18:45:50 allsky allsky.sh[4480]: /home/pi/allsky/allsky.sh: line 225:  4562 Killed "${ALLSKY_HOME}/${CAPTURE}" "${ARGUMENTS[@]}"
    +Jan 20 18:45:50 allsky allsky.sh[4480]: /home/pi/allsky/allsky.sh: line 372:
    +     4562 Killed "${ALLSKY_HOME}/${CAPTURE}" "${ARGUMENTS[@]}"
     Jan 20 18:45:50 allsky allsky.sh[4480]: 'capture_RPi' exited with RETCODE=137
     </pre>
     that usually means the Linux kernel killed the command because it was using
    @@ -152,8 +128,8 @@ <h2>RETCODE=137</h2>
     </blockquote>
     </p>
     <p>
    -This problem happens more often on Pi's with small amounts of memory such as the Pi Zero
    -with only 512 MB.
    +This problem happens more often on Pi's with small amounts of memory such as
    +the Pi Zero 2 with only 512 MB.
     It's less likely (although still possible) on a Pi 4 with 4 or 8 GB of memory.
     </p>
     </details>
    diff --git a/html/documentation/troubleshooting/ZWOCameras.html b/html/documentation/troubleshooting/ZWOCameras.html
    index d41525702..8eaccae3d 100644
    --- a/html/documentation/troubleshooting/ZWOCameras.html
    +++ b/html/documentation/troubleshooting/ZWOCameras.html
    @@ -16,6 +16,7 @@
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
     	<title>ZWO Camera Troubleshooting</title>
     </head>
     <body>
    @@ -27,45 +28,31 @@
     <h2>ZWO camera not found</h2>
     <details><summary></summary>
     <p>
    -If you have a fairly new ZWO camera model,
    -the ZWO software that's included with Allsky may not yet support it.
    -To see which cameras are supported, execute:
    -<pre>
    -strings ~/allsky/src/lib/armv7/libASICamera2.a  | \
    -	grep "_SetResolutionEv$" | \
    -	sed -e 's/^.*CameraS//' -e 's/17Cam//' -e 's/_SetResolutionEv//' | \
    -	sort -u \
    -&gt; /tmp/ZWOcameras.txt
    -</pre>
    -With a text editor, open <span class="fileName">/tmp/ZWOcameras.txt</span>
    -and search for your camera.
    -The file is about 1000 lines and is sorted.
    -If your don't find your camera, execute:
    -<pre>
    -grep "ZWO SDK" /var/log/allsky.log"
    -</pre>
    -and include the results in a new
    -<a href="https://github.com/AllskyTeam/allsky/issues">Issue</a>,
    -as well as your camera model.
    +If you have a <strong>new</strong> ZWO camera model that Allsky doesn't support
    +you'll get an appropriate message in the WebUI when you start Allsky.
    +The message will also tell you how to request support.
     </p>
     <p>
    -Other things to check:
    -<ul>
    -<li>Is the camera plugged in to a USB port?
    -	Try plugging it into a different port.
    -<li>Is the camera plugged in to a USB hub?
    -	Make sure the hub is plugged into the Pi.
    -	Try plugging the hub into a different port on the Pi.
    -	Temporarily bypass the hub.
    -<li>If you have access to another Pi, or a PC,
    -	plug the camera into it to see if it works.
    +If your camera <strong>is</strong> supported, check:
    +<ul class="minimalPadding">
    +	<li>Try plugging the camera into a different port.
    +	<li>Try a different USB cable - they DO go bad sometimes.
    +	<li>Is the camera plugged in to a USB hub?
    +		Make sure the hub is plugged into the Pi.
    +		Try plugging the hub into a different port on the Pi.
    +		Temporarily bypass the hub.
    +	<li>If you have access to another Pi, or a PC,
    +		plug the camera into it to see if it works.
     </ul>
     </p>
     </details>
     
     
    -<h2>ASI_ERROR_TIMEOUT Errors</h2>
    +<h2>ASI_ERROR_TIMEOUT and other 'strange' errors</h2>
     <details><summary></summary>
    +<blockquote>
    +This is should almost never happen with newer Allsky releases.
    +</blockquote>
     <p>
     If images are not being taken with your ZWO camera and you see many
     <b>ASI_ERROR_TIMEOUT</b> messages in <span class="fileName">/var/log/allsky.log</span>,
    @@ -82,37 +69,31 @@ <h2>ASI_ERROR_TIMEOUT Errors</h2>
     This condition may persist through a restart of Allsky.
     </p>
     <p>
    -If you are seeing lots of <b>ASI_ERROR_TIMEOUT</b> messages
    +If you are seeing lots of errors
     (an occasional one can be ignored) try the following, in this order:
     <ol>
     <li>If you have easy access to your Pi:
    -	<ul>
    +	<ul class="minimalPadding">
     	<li>Check if there is moisture or water in it and if so, let it dry out for a couple days.
     		This solved weird problems for some people,
     		such as 60 second exposures finishing in 2 seconds,
     		and continual ASI_ERROR_TIMEOUT messages.
     	<li>Unplug and replug the camera to fully reboot it.
    -	<li>Move the camera to a different port or from a USB 2 port to USB 3 or vice versa. 
    +	<li>Move the camera to a different port on the Pi
    +		or from a USB 2 port to USB 3 or vice versa. 
     	</ul>
    -<li>If you don't have easy access to the Pi <b>and you have the <code>uhubctl</code>
    -	command installed on your Pi</b>, make sure you set the
    -	<span class="shSetting">UHUBCTL_PATH</span> and <span class="shSetting">UHUBCTL_PORT</span>
    -	settings in the <span class="fileName">config.sh</span> file, then restart Allsky.
    -	The next time it fails due to ASI_ERROR_TIMEOUT errors it will reset the USB bus in an
    -	attempt to fix those errors.  
    -	You can get the <code>uhubctl</code> command by executing
    -	<code>sudo apt-get install uhubctl</code>.
     <li>Change the USB settings.
    -	In the WebUI, click on the <b>Camera Settings</b> link and look for the
    -	<span class="WebUISetting">USB Bandwidth</span> setting.
    -	Try increasing and decreasing it, and try turning
    -	<span class="WebUISetting">Auto USB Bandwidth</span> on and off.
    +	On the WebUI's <span class="WebUILink">Allsky Settings</span> page,
    +	try increasing and decreasing the
    +	<span class="WebUISetting">USB Bandwidth</span> setting
    +	and try turning <span class="WebUISetting">Auto USB Bandwidth</span> on and off.
     <li>See if the system is in under-voltage mode or is throttling which
     	could lead to insufficient power getting to the camera.
    -	In the WebUI, go to the <b>System</b> page;
    -	it will display the throttle and under voltage status.
    +	The WebUI's <span class="WebUILink">System</span> page
    +	will display the throttle and under voltage status.
         If you are getting under-voltage,
    -	<a href="https://lifepo4wered.com/lifepo4wered-pi.html">this power unit</a> may help.
    +	<a external="true" href="https://lifepo4wered.com/lifepo4wered-pi.html">this power unit</a>
    +	may help.
     	Or use a more powerful power supply or power cables with thicker wires.
     <li>If you have easy access to your Pi and have a spare powered USB hub,
     	plug the hub into your Pi and a power source, and plug the camera into the hub.
    @@ -120,21 +101,17 @@ <h2>ASI_ERROR_TIMEOUT Errors</h2>
     <li>If you have a Windows PC, plug the camera into it and use the ZWO software
     	that came with the camera to see if it also has problems.
     	If it does, it's likely a hardware issue.
    -<li>Revert to the pre-0.8 exposure method.
    -	In the WebUI, click on the <b>Camera Settings</b> link.
    -	Toggle the <span class="WebUISetting">Version 0.8 Exposure</span> setting
    -	then click on the
    -	<span class="btn btn-primary btn-not-real btn-small">Save changes</span> button.
     <li>The above changes work for almost everyone.
     	If they don't work for you, follow the instructions on the
    -	<a allsky="true" href="/documentation/troubleshooting/reportingIssues.html">Reporting Issues</a>
    -	Wiki page to report the problem.
    +	<a allsky="true" external="true" href="reportingIssues.html">Reporting Issues</a>
    +	page to report the problem.
     </ol>
     
     <h3>Advanced steps</h3>
     <p>
     The steps listed below haven't been fully tested and we suggest
    -entering a new <a href="https://github.com/AllskyTeam/allsky/discussions">Discussions</a>
    +entering a new
    +<a external="true" href="https://github.com/AllskyTeam/allsky/discussions">Discussions</a>
     item before trying them.
     </p>
     <p>
    @@ -164,9 +141,15 @@ <h3>Advanced steps</h3>
     
     <h2>USB 2.0 Cameras, e.g., ASI120</h2>
     <details><summary></summary>
    +<blockquote class="warning">
    +<strong>Many</strong> people with the ASI120 camera have problems and
    +bought better cameras.
    +Beware.
    +</blockquote>
     <p>
     Owners of USB2.0 cameras such as the ASI120 may need to do a
    -<a href="https://astronomy-imaging-camera.com/software-drivers">firmware upgrade</a>.
    +<a external="true"
    +	href="https://astronomy-imaging-camera.com/software-drivers">firmware upgrade</a>.
     </p>
     </details>
     
    @@ -175,9 +158,9 @@ <h2>T7 Cameras</h2>
     <details><summary></summary>
     <p>
     The T7 / T7C cameras from Datyson and other sellers are an OEM version of the ZWO ASI120 and
    -<b>isn't officially supported in Allsky</b>
    +<strong>isn't officially supported in Allsky</strong>
     because it does not work out of the box.
    -If you do decide to try this camera, you do so at your own risk.
    +If you do decide to try this camera, <strong>you do so at your own risk</strong>.
     </p>
     <p>
     We do not know if the camera has a fail-safe boot loader that will prevent
    @@ -210,7 +193,8 @@ <h2>T7 Cameras</h2>
     </p>
     <p>
     While we cannot help with the buyer's remorse, the USB errors can be remedied using
    -<a href="https://astronomy-imaging-camera.com/software-drivers">ZWO's compatible firmware</a>.
    +<a external="true"
    +	href="https://astronomy-imaging-camera.com/software-drivers">ZWO's compatible firmware</a>.
     Without this firmware the camera will not work on Linux. Unfortunately,
     the firmware updater must be run from Windows in order to update USB 2 cameras.
     ZWO support confirms that it does not work on MacOS or Linux.
    diff --git a/html/documentation/troubleshooting/hardware.html b/html/documentation/troubleshooting/hardware.html
    index 15bfb3c01..cfb93f943 100644
    --- a/html/documentation/troubleshooting/hardware.html
    +++ b/html/documentation/troubleshooting/hardware.html
    @@ -16,6 +16,7 @@
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
     	<title>Hardware Troublehooting</title>
     </head>
     <body>
    @@ -32,12 +33,13 @@
     <h2>My Pi isn't using all its disk space</h2>
     <details><summary></summary>
     <p>
    -If you have a large SD card like 64 GB but the WebUI's <b>System</b> page shows significantly less,
    -you need to resize the card.  For example you see this:
    -
    +If you have a large SD card like 64 GB but the WebUI's
    +<span class="WebUILink">System</span> page shows significantly less,
    +you need to resize the card.
    +For example you see this:
     <img src="notUsingAllDiskSpace.png" loading="lazy" class="imgCenter imgBorder" title="Not using all space">
    -
    -To fix this, resize your filesystem:
    +but your SD card is much bigger than 28 GB,
    +you need to resize your filesystem:
     <pre>
     sudo resize2fs /dev/sda2
     </pre>
    @@ -60,8 +62,9 @@ <h2>Pi keeps rebooting after a few minutes</h2>
     <blockquote>
     If you need to shut down your Pi do not simply unplug the power;
     instead, use the
    -<img allsky="true" src="/documentation/img/shutdownPi.png" class="buttonIconLarge" title="Shutdown button" loading="lazy">
    -button on the WebUI's <span class="WebUIPage">System</span> page or via the command prompt:
    +<img allsky="true" src="/documentation/img/shutdownPi.png"
    +	class="buttonIconLarge" title="Shutdown button" loading="lazy">
    +button on the WebUI's <span class="WebUILink">System</span> page or via the command prompt:
     <pre>
     sudo shutdown -r now
     </pre>
    @@ -69,22 +72,6 @@ <h2>Pi keeps rebooting after a few minutes</h2>
     </details>
     
     
    -<h2>Pi 3B+ doesn't run the latest AllSky Software</h2>
    -<details><summary></summary>
    -<p>
    -If your Pi either hangs while booting with a camera connected or it gives
    -ASI_ERROR_TIMEOUT messages when connecting the camera after booting, read on.
    -</p>
    -<p>
    -If you are running the much older "Stretch" version of Linux, upgrade the firmware:  
    -<pre>
    -sudo apt-get install rpi-update    # on Raspbian  
    -sudo rpi-update
    -</pre>
    -</p>
    -</details>
    -
    -
     <h2>Lenses</h2>
     <details><summary></summary>
     <p>
    @@ -93,9 +80,10 @@ <h2>Lenses</h2>
     If you have light interference near the horizon you can create an artificial horizon,
     with a piece of black PVC pipe for example.
     If you're unsure of the field of view you can use a tool like
    -<a href="https://www.bintel.com.au/tools/astronomy-calculator/">Bintel</a>
    +<a external="true" href="https://www.bintel.com.au/tools/astronomy-calculator/">Bintel</a>
     to calculate the field of view on the sensor,
    -or <a href="https://stellarium.org/">Stellarium</a> to simulate the field of view.
    +or <a external="true" href="https://stellarium.org/">Stellarium</a>
    +to simulate the field of view.
     </details>
     
     
    @@ -103,8 +91,9 @@ <h2>Moisture on dome</h2>
     <details><summary></summary>
     <p>
     If you are getting moisture on the inside of your allsky dome, consider adding a heater.
    -Click <a href="https://github.com/AllskyTeam/allsky/issues/113">here</a>
    -and <a href="https://www.instructables.com/Raspberry-Pi-Dew-Heater-for-All-sky-Camera/">here</a>.
    +Click <a external="true" href="https://github.com/AllskyTeam/allsky/issues/113">here</a>
    +and <a external="true"
    +	href="https://www.instructables.com/Raspberry-Pi-Dew-Heater-for-All-sky-Camera/">here</a>.
     </p>
     <p>
     You can also add a fan to the enclosure that sends air warmed by the Pi into the dome via one hole,
    diff --git a/html/documentation/troubleshooting/image-flicker.html b/html/documentation/troubleshooting/image-flicker.html
    index d26729f33..ba9f4b7a5 100644
    --- a/html/documentation/troubleshooting/image-flicker.html
    +++ b/html/documentation/troubleshooting/image-flicker.html
    @@ -11,11 +11,12 @@
     	<script src="../bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
     	<style>
     		#pageTitle::before {
    -			content: "Troubleshooting Image Flicker";
    +			content: "Troubleshooting Image Flicker or Brightness 'Strobe'";
     		} 
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
     	<title>Image Flicker</title>
     </head>
     <body>
    @@ -24,21 +25,24 @@
     <div class="Layout-sidebar" w3-include-html="/documentation/sidebar.html" id="sidebar"></div>
     <div class="Layout-main markdown-body" id="mainContents">
     
    +<p>
    +If your videos to change in brightness every image or "strobe", see below.
    +</p>
     
    -<h2>Images flicker or strobe in brightness</h2>
    -
    -<h3>During Daytime AND Nighttime</h3>
    +<h2>During daytime AND nighttime</h2>
     <p>
    -If images in the WebUI's Liveview mode or the Allsky Website flicker every
    -time a new image appears, it is probably because you are using the Firefox browser.
    -It has a known issue with image updates.
    -Please try a different browser.
    -To ensure the issue is browser-related,
    -look at the images saved in <span class="fileName">~/allsky/images/DATE</span>
    -to see if they flicker.
    +If images on the WebUI's
    +<span class="WebUILink">Liveview</span> page or the Allsky Website flicker every
    +time a new image appears, it could be the Firefox browser which
    +has a known issue with image updates.
    +Try a different browser.
    +To ensure the issue is browser-related, click on the
    +<span class="WebUILink">Images</span> link in the WebUI and view the images one at a time,
    +checking if their brightness changes every image.
    +If they do NOT, then it's a browser issue.
     </p>
     
    -<h3>Daytime only flicker - ZWO cameras</h3>
    +<h2>Daytime only flicker (ZWO cameras)</h2>
     <p>
     Daytime auto exposures with ZWO cameras use an AllSky algorithm that looks
     at part of the image (called the <b>histogram box</b>) to determine the average brightness,
    @@ -53,25 +57,16 @@ <h3>Daytime only flicker - ZWO cameras</h3>
     </p>
     <p>
     If you are STILL having flicker issues after doing the above,
    -please do the following so we can debug:
    -<pre>
    -sudo systemctl stop allsky
    -sudo truncate -s 0 /var/log/allsky.log
    -</pre>
    -In the WebUI change the <span class="WebUISetting">Debug Level</span> to
    -<span class="WebUIValue">4</span> then click on the
    -<span class="btn btn-primary btn-not-real btn-small">Save changes</span> button.
    -Let it run for a couple minutes so it includes the flickering,
    -then upload <span class="fileName">/var/log/allsky.log</span>.
    +please follow the instructions on the
    +<a allsky="true" external="true" href="reportingIssues.html">Reporting Issues</a> page.
     </p>
     
    -<h3>Nighttime only flicker - ZWO</h3>
    +<h2>Nighttime only flicker (ZWO cameras)</h2>
     <p>
     Nighttime images use the ZWO algorithm, not the Allsky algorithm for auto exposure.
     If you see flickering at night, try the following - these steps have helped some people,
     but not everyone:
     <ul>
    -<li>Toggle the <span class="WebUISetting">Version 0.8 Exposure</span> setting in the WebUI.
     <li>Change the <span class="WebUISetting">USB Bandwidth</span> and
     	<span class="WebUISetting">Auto USB</span> settings in the WebUI.
     <li>Add a more powerful power supply to the Pi,
    @@ -83,11 +78,6 @@ <h3>Nighttime only flicker - ZWO</h3>
     <li>Update the Pi's firmware by executing <code>sudo rpi-update</code>.
     </ul>
     
    -<blockquote>
    -We are still investigating nighttime flickering, since it only happens for some cameras,
    -and didn't happen with Allsky version 0.7.
    -</blockquote>
    -
     
     </div><!-- Layout-main -->
     </div><!-- Layout -->
    diff --git a/html/documentation/troubleshooting/increaseSwap.html b/html/documentation/troubleshooting/increaseSwap.html
    index b2caf6c1b..726882a2d 100644
    --- a/html/documentation/troubleshooting/increaseSwap.html
    +++ b/html/documentation/troubleshooting/increaseSwap.html
    @@ -16,6 +16,7 @@
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
     	<title>Increasing Swap Space</title>
     </head>
     <body>
    @@ -31,8 +32,7 @@
     It's easy to do:
     <pre>
     systemctl stop allsky
    -cd ~/allsky
    -./install.sh --function recheck_swap
    +allsky-config  recheck_swap
     systemctl start allsky
     </pre>
     </p>
    @@ -49,7 +49,7 @@
     <p>
     A more detailed description of how to manually increase swap space,
     including adding multiple swap files,
    -is <a href="https://itsfoss.com/create-swap-file-linux/">here</a>.
    +is <a external="true" href="https://itsfoss.com/create-swap-file-linux/">here</a>.
     </p>
     
     
    diff --git a/html/documentation/troubleshooting/other.html b/html/documentation/troubleshooting/other.html
    index faea514b7..c4e6c7944 100644
    --- a/html/documentation/troubleshooting/other.html
    +++ b/html/documentation/troubleshooting/other.html
    @@ -39,9 +39,7 @@ <h2>USB reset messages in system logs</h2>
     </pre>
     </p>
     <p>
    -The ZWO software adds these whenever video mode is turned on,
    -which is either once every time you start the AllSky Software,
    -or every time and image is taken, depending on your settings.
    +The ZWO software adds these whenever an image is taken.
     </p>
     
     </details>
    @@ -50,17 +48,22 @@ <h2>USB reset messages in system logs</h2>
     <h2>Can't save image because one is already being saved</h2>
     <details><summary></summary>
     <p>
    -This message appears in <span class="fileName">/var/log/allsky.log</span>
    +This message appears in the WebUI
     when the time to save and process an image is greater than the time to take an
    -image plus the delay between images.
    +image plus the
    +<span class="WebUISetting">Delay</span> between images.
     For example, if it takes 10 seconds to save and process an image but your exposure time is
    -1 second and your delay between exposures is 2 seconds (for a total of 3 seconds),
    +1 second and your
    +<span class="WebUISetting">Delay</span>
    +between exposures is 2 seconds (for a total of 3 seconds),
     the software will try to save the second picture while the first one is still being saved.
     </p>
     <p>
     <span class="fileName">png</span> files can take 10 or more seconds to save on a
     Pi 4 because there is no hardware support for them.
    -You may also see this message if the delay between images is too short, e.g., less than a second.
    +You may also see this message if the
    +<span class="WebUISetting">Delay</span>
    +between images is too short, e.g., less than a second.
     </p>
     <p>
     There also may be messages like these in <span class="fileName">/var/log/lighttpd/error.log</span>:
    @@ -74,21 +77,23 @@ <h2>Can't save image because one is already being saved</h2>
     <p>
     To fix this:
     <ul class="minimalPadding">
    -<li>Increase your delay or save to
    -<span class="fileName">jpg</span> format instead of <span class="fileName">png</span>.
    +<li>Increase your <span class="WebUISetting">Delay</span> or save to
    +	<span class="fileName">jpg</span> format instead of
    +	<span class="fileName">png</span>.
     <li>If you have an extremely slow SD card try replacing it with a faster one.
     	Or, make the <span class="fileName">allsky/tmp</span> directory a memory-filesystem
     	if not already done during installation.
     	See the
    -	<a allsky="true" href="/documentation/miscellaneous/FAQ.html">Reducing wear on your SDcard</a>
    -	tip.
    +	<a allsky="true" external="true"
    +		href="/documentation/miscellaneous/FAQ.html#SDwear">Reducing wear on your SD card</a>
    +	page.
     </ul>
     </details>
     
     
     <h2>Images are too light, too dark, or stars are hard to see</h2>
     <details><summary></summary>
    -<h3>Overall image too light or too dark</h3>
    +<h4>Overall image too light or too dark</h4>
     <p>
     If your images are too light or too dark and you are using <b>manual exposure</b>
     try adjusting the <span class="WebUISetting">Gain</span>,
    @@ -110,13 +115,15 @@ <h3>Overall image too light or too dark</h3>
     Note that every camera has a maximum gain and exposure it supports - hover your cursor
     over those values to see what the maximums are.
     </p>
    -<h3>Stars hard to see</h3>
    +<h4>Stars hard to see</h4>
     If the stars are too hard to see but the overall image image brightness is where you want it,
     try
    -<a allsky="true" href="/documentation/explanations/exposureGainSaturation.html#stretch">stretching</a>
    -the image which changes the contrast.
    +<a allsky="true" external="true"
    +	href="/documentation/explanations/exposureGainSaturation.html#stretch">stretching</a>
    +the image.
     </details>
     
    +
     <h2>Images are corrupted</h2>
     <details><summary></summary>
     <p>
    @@ -128,12 +135,11 @@ <h2>Images are corrupted</h2>
     <ul>
     <li>Check your power supply and cooling.
     	The Pi will report on undervoltage and/or throttled status on the
    -	<span class="WebUIWebPage">System</span> page of the WebUI.
    +	<span class="WebUILink">System</span> page of the WebUI.
     	If it doesn't say <b class="progress-bar-success">No throttling</b>
     	you should investigate and remediate.
     <li>If you have a ZWO camera it may be experiencing some internal firmware error;
    -	reset the camera either by unplugging it or,
    -	if you have the <code>uhubctl</code> command installed,
    +	reset the camera either by unplugging it or
     	power cycling the port with
     	<pre>sudo uhubctl -a 2 $(grep -l 03c3 /sys/bus/usb/devices/*/idVendor | \
     		cut -d / -f 6 | sed -Ee 's/(.*)[.]([0-9]+)$/-l \1 -p \2/')</pre>
    @@ -148,4 +154,3 @@ <h2>Images are corrupted</h2>
     </body>
     </html>
     <script> includeHTML(); </script>
    -
    diff --git a/html/documentation/troubleshooting/reportingIssues.html b/html/documentation/troubleshooting/reportingIssues.html
    index 81658195a..fdacdb3f0 100644
    --- a/html/documentation/troubleshooting/reportingIssues.html
    +++ b/html/documentation/troubleshooting/reportingIssues.html
    @@ -16,6 +16,7 @@
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
     	<title>Reporting Issues</title>
     </head>
     <body>
    @@ -26,57 +27,64 @@
     
     <p>
     If you have a <b>QUESTION</b> or want to suggest a <b>NEW FEATURE</b>,
    -add a new <a href="https://github.com/AllskyTeam/allsky/discussions">Discussion</a>
    +add a new
    +<a external="true" href="https://github.com/AllskyTeam/allsky/discussions">Discussion</a>
     item via the link at the top of any GitHub page; do <u>not</u> submit an Issue.
     </p>
     <p>
     If you found a <b>BUG</b> or something isn't working right,
     first look in the
    -<a href="https://github.com/AllskyTeam/allsky/discussions">Discussions</a>
    -area and at existing Issues.
    +<a external="true" href="https://github.com/AllskyTeam/allsky/discussions">Discussions</a>
    +area and at existing
    +<a external="true" href="https://github.com/AllskyTeam/allsky/issues">Issues</a>.
     If no one has already reported the problem, create a new Issue.
     </p>
    +
    +<h4>How to report an Issue</h4>
     <p>
    -We cannot debug a problem without the Allsky log file
    -(<span class="fileName">/var/log/allsky.log</span>) so if you want help you <b>must</b> attach it.
    -The following commands create the proper log file:
    +The first step is to create a log file
    +(<span class="fileName">/var/log/allsky.log</span>)
    +with the proper information to help us troubleshoot:
     <pre>
    -sudo systemctl stop allsky               # Stop the allsky service
    +sudo systemctl stop allsky               # Stop Allsky
     sudo truncate -s 0 /var/log/allsky.log   # clears out log file
    -# Do NOT restart the service yet
    +# Do NOT restart Allsky yet
     </pre>
     </p>
     <p>
     Change <span class="WebUISetting">Debug Level</span> to 4 in the WebUI then click on the
    -<span class="btn btn-primary btn-not-real btn-small">Save changes</span> button.
    -This will restart the service.
    +<span class="btn btn-primary btn-not-real btn-small">Save changes</span> button to
    +restart Allsky.
    +</p>
    +<p>
    +Wait until the problem occurs, then run:
    +<pre>
    +cp  /var/log/allsky.log  /tmp/allsky.log
    +cp  ~/allsky/config/settings.json  /tmp/settings.json.txt
    +</pre>
     </p>
     <p>
    -Wait until the problem occurs, then run <code>sudo systemctl stop allsky</code>
    -to stop Allsky so the log file doesn't keep growing, making it hard to read.
    -Then attach <span class="fileName">/var/log/allsky.log</span>
    -(<b>do <u>not</u> copy/paste it</b>) to your Issue in GitHub.
    -If the problem may be related to your configuration,
    -also attach <span class="fileName">~/allsky/config/config.sh</span>.
    +Create a new
    +<a external="true" href="https://github.com/AllskyTeam/allsky/issues">Issue</a>
    +in GitHub and <strong>attach</strong>
    +<span class="fileName">/tmp/allsky.log</span>
    +and <span class="fileName">/tmp/settings.json.txt</span> to it
    +(<strong>do <u>not</u> copy/paste their text</strong>).
    +</p>
    +<br>
     <p>
     <blockquote>
    -GitHub only accepts text files ending in <span class="fileName">.txt</span>
    +GitHub only accepts files ending in <span class="fileName">.txt</span>
     and <span class="fileName">.log</span>.
     If your file has a different extension,
    -append a <span class="fileName">.txt</span> to it, i.e.,
    -<span class="fileName">config.sh.txt</span>.
    +append a <span class="fileName">.txt</span> to it, e.g.,
    +<span class="fileName">settings.json.txt</span>.
     </blockquote>
     </p>
     <p>
     Do NOT zip your files; it makes it hard to read on a phone.
     If you feel you need to zip a file you probably didn't follow the instructions above.
     </p>
    -<p>
    -In your description, please include the output of <code>cd ~/allsky; git log | head</code>.
    -</p>
    -<blockquote>
    -When you create a new Issue, you will see a list of instructions and required information.
    -</blockquote>
     
     
     </div><!-- Layout-main -->
    diff --git a/html/documentation/troubleshooting/timelapse.html b/html/documentation/troubleshooting/timelapse.html
    index df860abc8..7e8675c60 100644
    --- a/html/documentation/troubleshooting/timelapse.html
    +++ b/html/documentation/troubleshooting/timelapse.html
    @@ -15,6 +15,7 @@
     		} 
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
    +	<link href="../css/custom.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
     	<title>Timelapse Problems</title>
     </head>
    @@ -26,64 +27,68 @@
     
     <p>
     Timelapse video creation can fail for a number of reasons.
    -Luckily following the troubleshooting steps below will almost always fix it.
    +The steps below, which apply to both the daily timelapse as well as mini timelapse,
    +will almost always fix it.
     <p>
     
     <h2>Timelapse video is not created</h2>
     <details><summary></summary>
     <p>
    -Using the WebUI's <span class="WebUIWebPage">Allsky Settings</span>
    -page, check that <span class="shSetting">TIMELAPSE</span> is "true"</span>
    -in <span class="fileName">config.sh</span>.
    -If it is, then you should see a message in
    +First make sure the timelapse is enabled.
    +In the <span class="settingsHeader">Timelapse Settings</span> section of the
    +WebUI's <span class="WebUILink">Allsky Settings</span> page,
    +check that <span class="WebUISetting">Generate</span>
    +setting for a Daily Timlapse is enabled;
    +for a mini-timelapse, make sure <span class="WebUISetting">Number of Images</span>
    +is <strong>not</strong> <span class="WebUIValue">0</span>.
    +</p>
    +
    +<p>
    +If the timelapse is enabled you should see a message in
     <span class="fileName">/var/log/allsky.log</span> like this:
     <pre>
     *** timelapse.sh: ERROR: ffmpeg failed.
    -Error log is in '/home/pi/allsky/tmp/timelapseTMP.txt'.
     </pre>
     (<code>ffmpeg</code> is the command that actually creates the video.)
     </p>
     <p>
    -Look at the <b>end</b> of the <span class="fileName">timelapseTMP.txt</span>
    -file for an error message that looks like one of the following:
    +See if one of the first lines in the error message is either:
     <pre>
    -/home/pi/allsky/scripts/timelapse.sh: line 128: 6546 Killed ffmpeg -y -f image2 -loglevel ...
    +/home/pi/allsky/scripts/timelapse.sh: line 294: 6546 Killed ffmpeg -y -f image2 -loglevel ...
     </pre>
     &nbsp; &nbsp; <b>OR</b>
     <pre>
     x264 [error]: malloc of size 38600544 failed
     </pre>
     <p>
    -These errors are almost always caused by either not enough RAM memory and swap space,
    +These errors are almost always caused by not enough RAM memory and swap space,
     or by having a large camera sensor (e.g., the RPi HQ).
     </p>
     <p>
     To fix, do the following:
    -<ul>
    +<ol>
     <li>Decide if you need a full-resolution video.
     	Most monitors only display High Definition (HD) which is 1920 x 1080 pixels.
    -	If your sensor size is larger than that you can decrease the
    -	<span class="shSetting">TIMELAPSEWIDTH</span> and
    -	<span class="shSetting">TIMELAPSEHEIGHT</span> settings in
    -	<span class="fileName">config.sh</span> to 1920 by 1080,
    +	If your sensor size is larger than that you can decrease the timelapse
    +	<span class="WebUISetting">Width</span> and
    +	<span class="WebUISetting">Height</span> settings to 1920 by 1080,
     	adjusted for the aspect ratio of your sensor.
     	Decreasing these values will also make the video file smaller.
     	Even if your monitor can display higher resolution than HD,
     	do other people who will view your video have monitors that can?  
     	If this doesn't solve your problem, do the steps below.
     
    -<li>If you saw the <b>Killed</b> error in <span class="fileName">timelapseTMP.txt</span>,
    +<li>If you saw the <code>Killed ffmpeg ...</code> error
     	increase swap space (or buy a Pi with more memory).
     	The Linux kernel will kill any process that is taking "too much" memory
    -	and swap space to avoid system issues.
    +	to avoid system issues.
     	Click <a allsky="true" href="increaseSwap.html">here</a>
     	for details on how to increase swap.
     
     <li>Install <code>htop</code> via <code>sudo apt install htop</code>.
    -	It's a nicer version of <code>top</code>, and is great for checking
    -	<code>ffmpeg</code>'s memory use.
    +	It's great for checking <code>ffmpeg</code>'s memory use.
     	Run <code>htop</code> in one terminal while running
    -	<code>scripts/generateForDay.sh -t DATE</code>
    +	<code>generateForDay.sh --timelapse DATE</code>
     	in another terminal to check on <code>ffmpeg</code>'s memory use.
     	<br>
     	If the number in the "VIRT" (virtual memory) column of <code>htop</code>
    @@ -91,55 +96,43 @@ <h2>Timelapse video is not created</h2>
     	adding swap space will <u>unlikely</u> fix the issue so you'll need to try one
     	of the other workarounds.
     
    -<li>The <b>malloc</b> error is often caused by using a very high resolution camera.
    -	The default video codec (<span class="shSetting">VCODEC</span>) in
    -	<span class="fileName">config.sh</span>
    -	is <span class="editorString">libx264</span>
    +<li>The <code>malloc of size ...</code> error is often caused by using a
    +	high resolution camera.
    +	The default video codec (<span class="WebUISetting">VCODEC</span>) setting
    +	is <span class="WebUIValue">libx264</span>
    +<!--- 4096 x 2304 is the max for Buster -->
     	which has a stated maximum resolution of 4096 x 2304.
    -	However, the RPi HQ, ZWO ASI183, and some newer cameras have resolutions higher than that.
    -	If you saw the <b>malloc</b> error do one of the following:
    +	However, the RPi HQ, ZWO ASI183, and other cameras have resolutions higher than that.
    +	If you saw the <code>malloc</code> error do one of the following:
     	<ul>
    -	<li>Decrease the <span class="shSetting">TIMELAPSEWIDTH</span>
    -		and <span class="shSetting">TIMELAPSEHEIGHT</span>
    -		variables in <span class="fileName">config.sh</span>.
    -		For the RPi HQ camera (and likely any other camera),
    -		the maximum that actually works is 3810 x 2828
    -		(which interestingly is over the maximum for the <span class="editorString">libx264</span>
    -		<span class="shSetting">VCODEC</span>).
    -   	<li>Use the <span class="editorString">libx265</span>
    -		<span class="shSetting">VCODEC</span> in
    -		<span class="fileName">config.sh</span> along with
    -		<span class="shSetting">TIMELAPSE_EXTRA_PARAMETERS</span>="<span class="editorString">-x265-params crf=30</span>".
    -		This <span class="shSetting">VCODEC</span> takes a <u>long</u> time to create a video
    -		(over 3 hours on a PI 4b with 1650 RPi HQ images versus about 15 minutes
    -		with <span class="editorString">libx264</span>).
    -		Lower <b>crf</b> numbers (a measure of file compression) mean larger,
    -		higher-quality videos.
    +	<li>Decrease the timelapse <span class="WebUISetting">Width</span>
    +		and <span class="WebUISetting">Height</span> settings.
    +	<li>Set the <span class="WebUISetting">VCODEC</span> setting to
    +   		<span class="WebUIValue">libx265</span> and set timelapse
    +		<span class="WebUISetting">Extra Parameters</span> to
    +		<span class="WebUIValue">-x265-params crf=30</span>.
    +
    +		This video codec takes a <u>long</u> time to create a video
    +		(over 3 hours on a PI 4b with 1500 RPi HQ images versus about 15 minutes
    +		with <span class="WebUIValue">libx264</span>).
    +		Lower <span class="WebUIValue">crf</span> numbers
    +		(a measure of file compression) mean larger, higher-quality videos.
     		Note that small changes to the number can result in <u>huge</u> difference in file size,
    -		for example, going from <span class="editorString">crf=30</span> to
    -		<span class="editorString">crf=25</span> can increase the video size by over <b>five</b> times.
    +		for example, going from <span class="WebUIValue">crf=30</span> to
    +		<span class="WebUIValue">crf=25</span>
    +		can increase the video size by over <b>five</b> times.
     	</ul>
    -</ul>
    -
    -</details>
    +</ol>
     
    -
    -<h2>Message in log file: "deprecated pixel format used, make sure you did set range correctly"</h2>
    -<details><summary></summary>
    -<p>
    -You can safely ignore this message.
    -</p>
     </details>
     
     
     <h2>Timelapse video doesn't have the correct number of images</h2>
     <details><summary></summary>
     <p>
    -The number of images (also called "frames") in a timelapse video is simply
    +The number of images (also called "frames") in a timelapse video is
     the number of seconds long it is (including fractions of a second) times the
    -Frames Per Second (FPS) you set in <span class="fileName">config.sh</span>.
    -</p>
    -<p>
    +Frames Per Second (<span class="WebUISetting">FPS</span>) you set.
     Execute the following to determine how many frames are in a video file as
     well as the length in seconds.
     Ignore the <code>fps</code> number,
    @@ -149,24 +142,22 @@ <h2>Timelapse video doesn't have the correct number of images</h2>
     </pre>
     </p>
     <p>
    -We believe one cause for videos that are too short is zero-length files.
    +One cause for videos that are too short is zero-length files.
     Apparently when <code>ffmpeg</code> encounters a zero-length file it quits.
    -Please make sure you have <span class="shSetting">REMOVE_BAD_IMAGES</span>
    -set to "true" in <span class="fileName">config.sh</span> so zero-length
    -and other "bad" images are not saved.
    -</p>
    -<p>
     To remove zero-length files for a specified day, do the following,
    -replacing "DATE" with the name of directory in <code>allsky/images</code>:
    -<pre>cd ~/allsky/scripts; ./removeBadImages.sh DATE</pre>.
    +replacing "DATE" with the name of a directory in <span class="fileName">~/allsky/images</span>:
    +<pre>removeBadImages.sh DATE</pre>
     It will take a while to run.
     When it's done you can re-create the timelapse by executing:
    -<pre>cd ~/allsky/scripts; ./generateForDay.sh -t DATE</pre>.
    +<pre>generateForDay.sh --timelapse DATE</pre>
     </p>
    +</details>
    +
    +
    +<h2>Message in log file: "deprecated pixel format used, make sure you did set range correctly"</h2>
     <p>
    -<span style="color: red">NEED MORE INFO ON WHAT TO CHECK/DO</span>
    +You can safely ignore this message.
     </p>
    -</details>
     
     
     </div><!-- Layout-main -->
    diff --git a/html/documentation/troubleshooting/uploads.html b/html/documentation/troubleshooting/uploads.html
    index 0b01ff62d..eab668d04 100644
    --- a/html/documentation/troubleshooting/uploads.html
    +++ b/html/documentation/troubleshooting/uploads.html
    @@ -16,6 +16,7 @@
     	</style>
     	<link href="../css/documentation.css" rel="stylesheet">
     	<link href="../documentation-favicon.ico" rel="shortcut icon" type="image/png">
    +	<script src="../js/all.min.js" type="application/javascript"></script>
     	<title>Upload Problems</title>
     </head>
     <body>
    @@ -25,123 +26,67 @@
     <div class="Layout-main markdown-body" id="mainContents">
     
     <p>
    -<b>Uploading</b> a file is the act of copying it to a different computer.
    -Allsky supports several ways to upload files, typically to a remote Allsky Website:
    +<b>Uploading</b> a file means copying it to a different computer.
    +Allsky supports several ways to upload files including
     <code>ftp</code>, <code>scp</code>, and others.
    -Uploads can fail for many reasons.
    -The most common ones are described below, as well as their fixes.
     </p>
     
    -
    -<h2>Uploads using FTP/FTPS/SFTP fail</h2>
    +<h3>Testing an upload</h3>
     <p>
    -The most common causes of FTP/FTPS/SFTP failures are:
    +After you've configured settings such as
    +<span class="WebUISetting">Server Name</span>
    +and
    +<span class="WebUISetting">Protocol</span>
    +for a remote Website or remote server,
    +run one of the commands below, depending on whether you are testing uploads
    +to a remote Allsky Website or a remote server:
    +<pre>
    +testUpload.sh --website
    +<span class="pl-c"> &nbsp; &nbsp; OR</span>
    +testUpload.sh --server
    +</pre>
     </p>
     
    -
    -<h3>Incorrect settings</h3>
    -<details><summary></summary>
    -<blockquote>
    -Most people use <code>ftp</code> or <code>sftp</code> to transfer files to another machine,
    -so this section will assume that.
    -The term "remote website" means a website not on your Pi,
    -but on a remote server, possibly one you pay for services on.
    -</blockquote>
     <p>
    -The <span class="shSetting">REMOTE_HOST</span>,
    -<span class="shSetting">REMOTE_USER</span>,
    -and <span class="shSetting">REMOTE_PASSWORD</span>
    -settings in <span class="fileName">ftp-settings.sh</span> must be correct.
    -If you can't log into to your remote server with that information, Allsky won't be able to either.
    - Single quotes in the password can cause a problem for Allsky.
    +This attempts to upload a file and if it fails,
    +it tries to determine why it failed and display how to fix it.
     </p>
     <p>
    -The values you entered into <span class="fileName">ftp-settings.sh</span> like
    -<span class="shSetting">IMAGE_DIR</span> and <span class="shSetting">KEOGRAM_DIR</span>
    -must match the directory structure on your FTP server.
    +If that doesn't help, look at the sections below for additional information.
     </p>
     
    +<h3>Incorrect WebUI settings or URL mapping</h3>
    +<p>
     <blockquote>
    -The concept of mapping URLs to file locations on a server is very important,
    -but not very intuitive to most people.
    -You may need to read this section multiple times.
    +Most people use <code>ftp</code> or <code>sftp</code> to transfer files to another machine,
    +so this section will assume that.
    +The term "remote Website" means an Allsky Website not on your Pi,
    +but on a remote server, possibly one you pay for services on.
     </blockquote>
    -<p>
    -When you enter a URL into a browser, how does the server know where the file is?
    -It typically starts looking in the root of the website,
    -which is usually specified by the web server administrator.
    -When you go to the Allsky WebUI, for example via <b>http://allsky</b>,
    -Allsky looks in <span class="fileName">/home/pi/allsky/html</span>
    -since that's what the Allsky developers specified.
    -</p>
    -<p>As you can see, there are two paths to the file - one via a URL and another
    -via the filesystem on the machine.
    -The web server software maps a URL to a file on a computer.
    -Sometimes that mapping is easy, and other times it's more difficult.
    -In both cases, it impacts what you enter into the settings in
    -<span class="fileName">ftp-settings.sh</span>.
    -</p>
    -
    -<h4>Easy case - URL matches server directory structure</h4>
    -<p>
    -Let's say you have a remote website at <b>https://mysite.com</b>
    -and you store family photos there.
    -When you FTP into your server you see the family photos.
    -This means your website URL is also the top-most directory of your remote
    -server's directory structure, called the "root" 
    -or <span class="fileName">/</span> directory.
    -</p>
    -<p>
    -Per Allsky's recommendations, you create a directory in the root of the remote server called
    -<span class="fileName">allsky</span> using your FTP client,
    -and copy all the files from the Allsky Website to that directory.
    -(See the
    -<a allsky="true" href="/documentation/installations/AllskyWebsite.html">Allsky Website Installation</a>
    -instructions for more details).
    -The URL to your Allsky Website would be <b>https://mysite.com/allsky</b>.
    -In <span class="fileName">ftp-settings.sh</span> you would set
    -<span class="shSetting">IMAGE_DIR</span>="<span class="editorString">/allsky</span>"
    -(or <span class="shSetting">IMAGE_DIR</span>="<span class="editorString">allsky</span>",
    -depending on your service provider).
    -You would also have
    -<span class="shSetting">KEOGRAM_DIR</span>="<span class="editorString">/allsky/keogram</span>",
    -and so on.
    -</p>
    -
    -
    -<h4>More difficult case - URL does not match directory structure</h4>
    -<p>
    -You have the same website URL as above,
    -but when you FTP into your server you don't see the photos;
    -instead, you see a directory with the same name as your user name,
    -for example, <span class="fileName">myusername</span>
    -(the actual directory(s) you see will depend on your service provider).
    -When you go into the <span class="fileName">myusername</span>
    -directory with your FTP program you see your photos.
     </p>
     <p>
    -In this case your web server is mapping the <b>https://myallsky.com</b> URL to the
    -<span class="fileName">/myusername</span> directory,
    -and you need to copy files to the <span class="fileName">/myusername</span>
    -directory or anything below it.
    +The <span class="WebUISetting">Server Name</span>,
    +<span class="WebUISetting">User Name</span>,
    +and <span class="WebUISetting">Password</span>
    +settings in WebUI must be correct.
    +If you can't log into to your remote server with that information,
    +Allsky won't be able to either.
    +Single quotes in the password can cause a problem for Allsky.
     </p>
     <p>
    -You install the Allsky Website in the <span class="fileName">/myusername/allsky</span>
    -directory on the server.
    -The URL to the Allsky Website is still <b>https://mysite.com/allsky</b>,
    -but your <span class="fileName">ftp-settings.sh</span> needs settings like
    -<span class="shSetting">IMAGE_DIR</span>="<span class="editorString">/myusername/allsky</span>"
    -and so on.
    +The directory structure on your remote server must match the Allsky standard;
    +changing locations and/or names of files and directories on the remote Website
    +will cause uploads to fail.
     </p>
     <p>
    -Mapping of URLs to directories like this is fairly common on remote servers that
    -are shared by many people.
    +If you don't know the difference in a file location on a server and
    +its associated URL, check out the
    +<a allsky="true" external="true" href="/documentation/explanations/serverLocationToURL.html">
    +Mapping server locations to URLs</a> page.
     </p>
    -</details>
     
     
     <h3>Certificate-related errors</h3>
    -<details><summary></summary>
     <p>
     There are three main certificate-related error messages you'll see.
     </p>
    @@ -165,9 +110,8 @@ <h3>Certificate-related errors</h3>
     	<li>Create a file called <span class="fileName">~/.lftprc</span> and add
     	<code>set ssl:check-hostname false</code> to it:
     		<pre>echo "set ssl:check-hostname" &gt; ~/.lftprc</pre>
    -	<li>Set <span class="shSetting">LFTP_COMMANDS</span> in
    -		<span class="fileName">ftp-settings.sh</span> to
    -		<code>set ssl:check-hostname false</code>.
    +	<li>Set the <span class="WebUISetting">FTP Commands</span> setting to
    +		<span class="WebUIValue">set ssl:check-hostname false</span>.
          	If you expect to execute <code>lftp</code> manually the first method is better,
     		otherwise use the second method so all your configuration changes are in one place.
     	</ol>
    @@ -175,7 +119,7 @@ <h3>Certificate-related errors</h3>
     <li><b>Certificate verification: Not trusted</b>
     	<br>
     	Add <code>set ssl:verify-certificate no</code> to
    -	<span class="shSetting">LFTP_COMMANDS</span> or the
    +	<span class="WebUISetting">FTP Commands</span> or the
     	<span class="fileName">~/.lftprc</span> file as above.
     </ol>
     If your Pi's IP address changed, or the IP address remained but the
    @@ -184,58 +128,18 @@ <h3>Certificate-related errors</h3>
     If the file has only one entry, simply remove it.
     If the file has multiple entries, make a backup of the file,
     then delete one entry at a time until you find the "bad" one.
    -</details>
    -
    -
    -<h3>Missing directories on the remote server</h3>
    -<details><summary></summary>
    -<p>
    -<b>YOU</b> need to create the necessary directories on the remote server.
    -For example, if <span class="fileName">ftp-settings.sh</span> has
    -<span class="shSetting">KEOGRAM_DIR</span>=<span class="editorString">/allsky/keogram</span>,
    -make sure your remote server has a directory called <span class="fileName">/allsky/keogram</span>.
    -Note that some servers don't accept the leading
    -<span class="fileName">/</span> so you may need to set
    -<span class="shSetting">KEOGRAM_DIR</span>="<span class="editorString">allsky/keogram</span>".
    -</p>
    -
    -</details>
     
     
    -<h3>Still having problems uploading?</h3>
    -<details><summary></summary>
    +<h3>Missing directories on the remote Website or server</h3>
     <p>
    -Try this from the command line and see what messages it produces
    -(replace <code>XXXXX</code> with the value of <span class="shSetting">IMAGE_DIR</span> in the
    -<span class="fileName">ftp-settings.sh</span> file):  
    -<pre>
    -echo hi &gt; /tmp/test.txt
    -~/allsky/scripts/upload.sh --debug /tmp/test.txt XXXXX test.txt
    -</pre>
    -</p>
    -<p> 
    -This should upload the <span class="fileName">/tmp/test.txt</span>
    -file to the same directory where your images go.
    -It will also display the directory name it's in as well as the contents of that directory;
    -this can help you determine if your path names are correct or not.
    -</p>
    -<p>
    -A file called <span class="fileName">~/allsky/tmp/lftp_cmds/x.txt</span>
    -will be created that contains the <code>lftp</code> commands used to upload
    -<span class="fileName">/tmp/test.txt</span>.
    -Look in that file to make sure the username and other information is correct.
    -If not, fix them in <span class="fileName">ftp-settings.sh</span>.
    -</p>
    -<p>
    -To see lftp messages line-by-line, do the following:
    -<pre>
    -export LFTP_PASSWORD=<i>your_REMOTE_PASSWORD</i>
    -lftp
    -</pre>
    -then copy each line, one at a time, from the <span class="fileName">x.txt</span>
    -file and paste it into the terminal window.
    +Review the 
    +<a allsky="true" external="true"
    +href="/documentation/installations/AllskyWebsite.html">Allsky Website installation instructions</a>
    +or the 
    +<a allsky="true" external="true"
    +href="/documentation/installations/RemoteServer.html">Remote server installation instructions</a>
    +to determine how to create the necessary directories.
     </p>
    -</details>
     
     
     </div><!-- Layout-main -->
    diff --git a/html/includes/admin.php b/html/includes/admin.php
    index 926ecdec9..d9158eac7 100644
    --- a/html/includes/admin.php
    +++ b/html/includes/admin.php
    @@ -1,10 +1,9 @@
     <?php
     
    -include_once( 'includes/status_messages.php' );
    -
     function DisplayAuthConfig($username, $password) {
     	global $page;
    -	$status = new StatusMessages();
    +	$myStatus = new StatusMessages();
    +
     	if (isset($_POST['UpdateAdminPassword'])) {
     		// Update the password
     		if (CSRFValidate()) {
    @@ -13,31 +12,31 @@ function DisplayAuthConfig($username, $password) {
     			$new1 = $_POST['newpass'];
     			$new2 = $_POST['newpassagain'];
     			if ($new_username == "") {
    -				$status->addMessage('You must enter the username.', 'danger');
    +				$myStatus->addMessage('You must enter the username.', 'danger');
     			}
     			if ($old == "" || $new1 == "" || $new2 == "") {
    -				$status->addMessage('You must enter the old (current) password, and the new password twice.', 'danger');
    +				$myStatus->addMessage('You must enter the old (current) password, and the new password twice.', 'danger');
     			} else if (password_verify($old, $password)) {
     				if ($new1 != $new2) {
    -					$status->addMessage('New passwords do not match.', 'danger');
    +					$myStatus->addMessage('New passwords do not match.', 'danger');
     				} else if ($new_username == '') {
    -					$status->addMessage('Username must not be empty.', 'danger');
    +					$myStatus->addMessage('Username must not be empty.', 'danger');
     				} else {
     					$contents = $new_username.PHP_EOL;
     					$contents .= password_hash($new1, PASSWORD_BCRYPT).PHP_EOL;
     					$ret = updateFile(RASPI_ADMIN_DETAILS, $contents, "admin password file", true);
     					if ($ret === "") {
     						$username = $new_username;
    -						$status->addMessage("$new_username password updated.", 'success');
    +						$myStatus->addMessage("$new_username password updated.", 'success');
     					} else {
    -						$status->addMessage($ret, 'danger');
    +						$myStatus->addMessage($ret, 'danger');
     					}
     				}
     			} else {
    -				$status->addMessage('Old password does not match.', 'danger');
    +				$myStatus->addMessage('Old password does not match.', 'danger');
     			}
     		} else {
    -			error_log('CSRF violation');		// TODO: need better message
    +			error_log('CSRF violation');
     		}
     	}
     ?>
    @@ -47,7 +46,7 @@ function DisplayAuthConfig($username, $password) {
     		<div class="panel panel-primary">
     			<div class="panel-heading"><i class="fa fa-lock fa-fw"></i> Change Admin Username and/or Password</div>
     			<div class="panel-body">
    -				<?php if ($status->isMessage()) echo "<p>" . $status->showMessages() . "</p>"; ?>
    +				<?php if ($myStatus->isMessage()) echo "<p>" . $myStatus->showMessages() . "</p>"; ?>
     
     				<form role="form" action="?page=<?php echo $page ?>" method="POST">
     					<?php CSRFToken() ?>
    diff --git a/html/includes/allskySettings.php b/html/includes/allskySettings.php
    index d7a07fa5d..615607215 100644
    --- a/html/includes/allskySettings.php
    +++ b/html/includes/allskySettings.php
    @@ -1,27 +1,182 @@
     <?php
     
    -function DisplayAllskyConfig(){
    -	global $formReadonly;
    +$debug = false;
     
    -	$cameraTypeName = "cameraType";		// json setting name
    -	$cameraModelName = "cameraModel";	// json setting name
    -	$cameraNumberName = "cameraNumber";	// json setting name
    -	$debugLevelName = "debuglevel";		// json setting name
    -	$debugArg = "";
    +// Get the json for the given file if we haven't already and return a pointer to the data.
    +// Most of the time there will only be one source file, so check if we're getting the
    +// same file as last time.
    +function &getSourceArray($file) {
    +	global $status, $debug;
    +
    +	static $filesContents = array();
    +	static $lastFile = null;
    +
    +	$fileName = getFileName($file);
    +	if ($fileName == "") {
    +		$errorMsg = "Unable to get file name for '$file'. Some settings will not work.";
    +		$status->addMessage($msg, 'danger');
    +		return ("");
    +	}
    +
    +	if ($fileName === $lastFile) return($filesContents[$fileName]);
    +
    +	$lastFile = $fileName;
    +
    +	if (! isset($filesContents[$fileName])) {
    +		$errorMsg = "Unable to read source file from $file.";
    +		$retMsg = "";
    +		$filesContents[$fileName] = get_decoded_json_file($fileName, true, $errorMsg, $retMsg);
    +		if ($filesContents[$fileName] === null || $retMsg !== "") {
    +			$status->addMessage($retMsg, 'danger');
    +		}
    +	}
    +//x echo "<br><pre>return fileName=$fileName: "; var_dump($filesContents); echo "</pre>";
    +	return $filesContents[$fileName];
    +}
    +
    +// Return "true" or "false" if $b is a boolean, depending on the value.
    +// This is used when outputing a boolean.
    +function toString($b) {
    +	if (gettype($b) == "boolean") {
    +		if ($b) return("true");
    +		return("false");
    +	}
    +	return($b);
    +}
    +
    +// Error checking functions.
    +function formatSettingName($name) {
    +	return("<span class='WebUISetting'>$name</span>");
    +}
    +function formatSettingValue($value) {
    +	return("<span class='WebUIValue'>$value</span>");
    +}
    +
    +// Determine the logical type based on the actual type.
    +function getLogicalType($type) {
    +	if (strpos($type, "text") !== false || $type == "password" || $type === "color") {
    +		return("text");
    +	} else if (strpos($type, "integer") !== false) {
    +		return("integer");
    +	} else if ($type == "float" || $type === "percent") {
    +		return("float");
    +	} else {
    +		return($type);
    +	}
    +}
    +
    +
    +// Check the value for the correct type.
    +// Return "" on success and some string on error.
    +function checkType($fieldName, $value, $old, $label, $type, &$shortened=null) {
    +	if ($type === null || $type === "text" || $value === "") {
    +		return("");
    +	}
    +
    +	$msg = "";
    +
    +	// $value may be of type string, even if it's actually a number
    +	// or a boolean, and only is_numeric() accounts for types of string.
    +	if ($type === "integer") {
    +		if (! is_numeric($value) || ! is_int($value + 0))
    +			$msg = "without a fraction";
    +		else
    +			$value += 0;
    +	} else if ($type === "float") {
    +		if (! is_numeric($value) || ! is_float($value + 0.0))
    +			$msg = "with, or without, a fraction";
    +		else
    +			$value += 0.0;
    +	}
    +	if ($msg === "") {
    +		return("");
    +	}
    +
    +	$msg = "must be a number $msg.";
    +	$shortened .= "It $msg";
    +	if ($value === $old) {
    +		$msg .= " The saved value is: ";
    +		$msg .= formatSettingValue($value);
    +	} else {
    +		$msg .= " You entered: ";
    +		$msg .= formatSettingValue($value);
    +	}
    +
    +	if (substr($fieldName, 0, 3) === "day") $label = "Daytime $label";
    +	else if (substr($fieldName, 0, 5) == "night") $label = "Nighttime $label";
    +
    +	return(formatSettingName($label) . " $msg");
    +}
     
    -	global $lastChangedName;			// name of json setting
    +// Return $value as type $type.
    +// This eliminates the need for JSON_NUMERIC_CHECK, which converts some
    +// strings we want as strings to numbers, e.g., "longitude = +105.0" should stay as a string.
    +function setValue($name, $value, $type) {
    +	if ($type === "integer") {
    +		return (int) $value;
    +	} else if ($type === "float") {
    +		return (float) $value;
    +	} else {
    +		return $value;
    +	}
    +}
    +
    +// ============================================= The main function.
    +function DisplayAllskyConfig() {
    +	global $formReadonly, $settings_array;
    +	global $useLocalWebsite, $useRemoteWebsite;
    +	global $debug;
    +	global $lastChangedName;				// name of json setting
     	global $lastChanged;
     	global $page;
     	global $ME;
     	global $status;
    +	global $endSetting;
     
    +	$cameraTypeName = "cameratype";			// json setting name
    +	$cameraModelName = "cameramodel";		// json setting name
    +	$cameraNumberName = "cameranumber";		// json setting name
    +	$debugLevelName = "debuglevel";			// json setting name
    +	$debugArg = "";
    +	$cmdDebugArg = "";		// set to --debug for runCommand() debugging
    +	$hideHeaderBodies = true;
    +	$numErrors = 0;
    +	$fromConfiguration = false;
    +	$bullet = "<div class='bullet'>*</div>";
    +	$showIcon = "<i class='fa fa-chevron-down fa-fw'></i>";
    +	$hideIcon = "<i class='fa fa-chevron-up fa-fw'></i>";
    +
    +//x	$mode = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_NUMERIC_CHECK | JSON_PRESERVE_ZERO_FRACTION;
    +	$mode = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION;
     	$settings_file = getSettingsFile();
     	$options_file = getOptionsFile();
     
     	$errorMsg = "ERROR: Unable to process options file '$options_file'.";
     	$options_array = get_decoded_json_file($options_file, true, $errorMsg);
     	if ($options_array === null) {
    -		exit;
    +		exit(1);
    +	}
    +
    +	// If there's no last changed date, they haven't configured Allsky,
    +	// which sets $lastChanged.
    +	$needsConfiguration = ($lastChanged === "");
    +	if ($needsConfiguration || $formReadonly) {
    +		$hideHeaderBodies = false;		// show all the settings
    +	}
    +
    +	$error_array = array();
    +	$error_array_short = array();
    +	$error_array_source = array();
    +
    +	// Keep track of required settings and the type of every setting.
    +	// This will be used to check for errors.
    +	$optional_array = array();
    +	$type_array = array();
    +	foreach ($options_array as $option) {
    +		$name = $option['name'];
    +		$optional_array[$name] = toBool(getVariableOrDefault($option, 'optional', "false"));
    +		$t = getVariableOrDefault($option, 'type', "");
    +		$type_array[$name] = $t;
     	}
     
     	if (isset($_POST['save_settings'])) {
    @@ -30,194 +185,441 @@ function DisplayAllskyConfig(){
     		// If we went ahead and made the changes, we would be making them to the NEW
     		// camera's settings file, but using values from the OLD file.
     		if (CSRFValidate()) {
    -			$settings = array();
    -			$optional_array = array();
    +			$sourceFiles = array();			// list of files in the "source" field
    +			$sourceFilesContents = array();	// contents of each sourceFiles file
     			$changes = "";
    -			$somethingChanged = false;
    -
    +			$restartRequired = false;
    +			$stopRequired = false;
    +			$reReadSettings = false;
    +			$cameraChanged = false;
     			$refreshingCameraType = false;
     			$newCameraType = "";
     			$newCameraModel = "";
     			$newCameraNumber = "";
    +			$twilightDataChanged = false;
     
     			$ok = true;
     
    -			// Keep track of optional settings
    -			foreach ($options_array as $option){
    -				$n = $option['name'];
    -				$optional_array[$n] = getVariableOrDefault($option, 'optional', false);
    +			// Keep track of which settings are from a different source.
    +			foreach ($options_array as $option) {
    +				$name = $option['name'];
    +				$s = getVariableOrDefault($option, 'source', null);
    +				if ($s !== null) {
    +					$fileName = getFileName($s);
    +					$sourceFiles[$name] = $fileName;
    +					$sourceFilesContents[$name] = &getSourceArray($fileName);
    +				}
     			}
     
    -	 		foreach ($_POST as $key => $value){
    +			$numSettingsChanges = 0;
    +			$numSourceChanges = 0;
    +			$nonCameraChanges = "";
    +			$changesMade = false;
    +
    +	 		foreach ($_POST as $name => $newValue) {
     				// Anything that's sent "hidden" in a form that isn't a settings needs to go here.
    -				if (in_array($key, ["csrf_token", "save_settings", "reset_settings", "restart", "page", "_ts", "XX_END_XX"]))
    +				if (in_array($name, ["csrf_token", "save_settings", "reset_settings",
    +						"restart", "page", "_ts", $endSetting, "fromConfiguration"])) {
    +					if ($name === "fromConfiguration") {
    +						// If set, the prior screen said "you must configure Allsky ..." so it's
    +						// ok if nothing changed, but we need to update $lastChanged.
    +						$fromConfiguration = $newValue;
    +					}
     					continue;
    +				}
     
     				// We look into POST data to only select settings.
     				// Because we are passing the changes enclosed in single quotes below,
     				// we need to escape the single quotes, but I never figured out how to do that,
     				// so convert them to HTML codes instead.
    -				$isOLD = substr($key, 0, 4) === "OLD_";
    -				if ($isOLD) {
    -					$key = substr($key, 4);		// everything after "OLD_"
    -					$oldValue = str_replace("'", "&#x27", $value);
    -					$newValue = getVariableOrDefault($settings, $key, "");
    -					if ($oldValue !== $newValue) {
    -						if ($key === $cameraTypeName) {
    -							if ($newValue === "Refresh") {
    +				$source_array = getVariableOrDefault($sourceFilesContents, $name, null);
    +				if ($source_array !== null) {
    +					$oldValue = getVariableOrDefault($source_array, $name, "");
    +					$isSettingsField = false;
    +				} else {
    +					$oldValue = getVariableOrDefault($settings_array, $name, "");
    +					$isSettingsField = true;		// this field is in the settings file.
    +				}
    +
    +				// Check for empty non-optional settings and valid numbers, and
    +				// get some info about the setting we'll need if it changed.
    +				// Do this for ALL settings, not just changed ones so we can
    +				// let the user know if there's a problem with an existing value.
    +				$checkchanges = false;
    +				$label = "??";
    +				$found = false;
    +				$type = "";
    +				$logicalType = "";
    +				foreach ($options_array as $option) {
    +					if ($option['name'] !== $name) continue;
    +
    +					$label = getVariableOrDefault($option, 'label', "");
    +					$found = true;
    +					$shortMsg = "";
    +					$type = $type_array[$name];
    +					$logicalType = getLogicalType($type);
    +					$newValue = setValue($name, $newValue, $logicalType);
    +					$oldValue = setValue($name, $oldValue, $logicalType);
    +					$e = checkType($name,
    +							$newValue,
    +							$oldValue,
    +							$label,
    +							$logicalType,
    +							$shortMsg);
    +					if ($e != "") {
    +						$ok = false;
    +						$numErrors++;
    +						$error_array[$name] = $e;
    +						$error_array_short[$name] = $shortMsg;
    +						if ($oldValue === $newValue)		// where did the error come from?
    +							$error_array_source[$name] = "db";
    +						else
    +							$error_array_source[$name] = "user";
    +
    +						if ($oldValue !== $newValue) {
    +							// Set to $newValue so the user sees the bad value.
    +							$settings_array[$name] = $newValue;
    +						}
    +					}
    +
    +					$optional = $optional_array[$name];
    +					if ($newValue !== "" || $optional) {
    +						$checkchanges = toBool(getVariableOrDefault($option, 'checkchanges', "false"));
    +					}
    +					$action = getVariableOrDefault($option, 'action', "none");
    +					break;
    +				}
    +
    +				if (! $found) {
    +					$msg = "Setting '$name' not in options file.";
    +					$status->addMessage($msg, 'danger');
    +					$ok = false;
    +				} else {
    +					if (toBool(getVariableOrDefault($option, 'settingsonly', "false"))) {
    +						// "settingsonly" settings aren't changed in the WebUI
    +						$settings_array[$name] = setValue($name, $oldValue, $logicalType);
    +						continue;
    +					}
    +
    +					if ($logicalType === "text") {
    +						if ($oldValue !== "")
    +							$oldValue = str_replace("'", "&#x27", $oldValue);
    +						if ($newValue !== "")
    +							$newValue = str_replace("'", "&#x27", $newValue);
    +					}
    +
    +					if ($oldValue === $newValue) {
    +						continue;
    +					}
    +
    +					if ($isSettingsField) $numSettingsChanges++;
    +					else $numSourceChanges++;
    +
    +					if ($name === $cameraTypeName) {
    +						if ($newValue === "Refresh") {
    +							if ($cameraChanged) {
    +								$msg = "If you selected <b>Refresh</b> for <b>$label</b>";
    +								$msg .= " you cannot change anything else.";
    +								$msg .= "<br>You also changed: ";
    +								if ($newCameraModel !== "") $msg .= " <b>Camera Model</b>";
    +								if ($newCameraNumber !== "") $msg .= " <b>Camera Number</b>";
    +								$status->addMessage($msg, 'danger');
    +								$ok = false;
    +							} else {
     								// Refresh the same Camera Type
     								$refreshingCameraType = true;
     								$newCameraType = $oldValue;
     								$newValue = $oldValue;
    -							} else {
    -								$newCameraType = $newValue;
     							}
    -						} elseif ($key === $cameraModelName) {
    -							$newCameraModel = $newValue;
    -						} elseif ($key === $cameraNumberName) {
    -							$newCameraNumber = $newValue;
     						} else {
    -							$somethingChanged = true;	// want to know about OTHER changes
    +							$newCameraType = $newValue;
     						}
    +						$cameraChanged = true;
     
    -						$checkchanges = false;
    -						foreach ($options_array as $option){
    -							if ($option['name'] === $key) {
    -								$optional = $optional_array[$key];
    -								if ($newValue !== "" || $optional) {
    -									$checkchanges = getVariableOrDefault($option, 'checkchanges', false);
    -									$label = getVariableOrDefault($option, 'label', "");
    -								}
    -								break;
    -							}
    +					} elseif ($name === $cameraModelName || $name === $cameraNumberName) {
    +						if ($refreshingCameraType) {
    +							$msg = "If you selected <b>Refresh</b> for <b>Camera Type</b>";
    +							$msg .= " you cannot change anything else.";
    +							$msg .= "<br>You also changed: <b>$label</b>";
    +							$status->addMessage($msg, 'danger');
    +							$ok = false;
    +						} else {
    +							$cameraChanged = true;
    +							if ($name === $cameraModelName)
    +								$newCameraModel = $newValue;
    +							else
    +								$newCameraNumber = $newValue;
    +						}
    +
    +					} else {
    +						// want to know changes other than camera
    +						if ($nonCameraChanges === "")
    +							$nonCameraChanges = "<b>$label</b>";
    +						else
    +							$nonCameraChanges .= ", <b>$label</b>";
    +						$nonCameraChanges .= " (from '$oldValue' to '$newValue')";
    +						if ($name === "latitude" ||
    +							  $name === "longitude" ||
    +							  $name === "angle" ||
    +							  $name === "takedaytimeimages" ||
    +							  $name === "takenighttimeimages") {
    +							$twilightDataChanged = true;
     						}
    -						if ($checkchanges)
    -							$changes .= "  '$key' '$label' '$oldValue' '$newValue'";
     					}
     
    -				} else {
    -					// Check for empty non-optional settings and valid numbers.
    -					$span = "span class='WebUISetting'";
    -					$spanValue = "span class='WebUIValue'";
    -					foreach ($options_array as $option) {
    -						if ($option['name'] === $key) {
    -							$type = getVariableOrDefault($option, 'type', null);
    -							$lab = $option['label'];
    -
    -							if ($value == "" && ! $optional_array[$key]) {
    -								$msg = "<$span>$lab</span> is empty";
    -								$status->addMessage($msg, 'danger', false);
    -								$ok = false;
    +					if ($action == "restart" || $action == "reload") {
    +						$restartRequired = true;
    +					} else if ($action == "stop") {
    +						$stopRequired = true;
    +					}
     
    -							} else if ($type !== null) {
    -								$msg = "";
    -								// $value will be of type string, even if it's actually a number,
    -								// and only is_numeric() accounts for types of string.
    -								if ($type === "integer" || $type == "percent") {
    -									if (! is_numeric($value) || ! is_int($value + 0))
    -										$msg = "without a fraction";
    -								} else if ($type === "float") {
    -									if (! is_numeric($value) || ! is_float($value + 0.0))
    -										$msg = "with, or without, a fraction";
    -								}
    -								if ($msg !== "") {
    -									$msg2 = "<$span>$lab</span> must be a number $msg.";
    -									$msg2 .= " You entered: <$spanValue>$value</span>";
    -									$status->addMessage($msg2, 'danger', false);
    -									$ok = false;
    -								}
    -							}
    -						}
    +					if ($checkchanges) {		// Changes for makeChanges.sh to check
    +						$changes .= "  '$name' '$label' '$oldValue' '$newValue'";
     					}
    +				}
     
    -					if ($ok) {
    -						$settings[$key] = str_replace("'", "&#x27", $value);
    +				$changesMade = ($numSettingsChanges > 0 || $numSourceChanges > 0);
    +				if ($ok && $changesMade) {
    +					// Update the appropriate array with the new value.
    +					if ($newValue === "true") {
    +						$newValue = true;
    +						$s_newValue = "true";
    +					} else if ($newValue === "false") {
    +						$newValue = false;
    +						$s_newValue = "false";
    +					} else {
    +						// Don't do unless needed:
    +						//	str_replace() changes non-strings like numbers to strings.
    +						if ($logicalType === "text")
    +							$newValue = str_replace("'", "&#x27", $newValue);
    +						$s_newValue = $newValue;
    +					}
     
    -						if ($key === $debugLevelName && $value >= 4) {
    -							$debugArg = "--debug";
    -						}
    +					if (isset($sourceFilesContents[$name])) {
    +if ($debug) {
    +	$s = toString($sourceFileContents[$name][$name]);
    +	echo "<br>sourceFilesContent[$name][$name] = $s, newValue=$s_newValue";
    +}
    +						$sourceFilesContents[$name][$name] = $newValue;
    +						$fileName = $sourceFiles[$name];
    +						$sourceFilesChanged[$fileName] = $fileName;
    +					} else {
    +						$settings_array[$name] = setValue($name, $newValue, $logicalType);
    +					}
    +
    +					if ($name === $debugLevelName && $newValue >= 4) {
    +						$debugArg = "--debug";
     					}
     				}
     			}
     
    -			$msg = "";
    -			if ($ok) {
    -				if ($somethingChanged || $lastChanged === "") {
    -					if ($newCameraType !== "" || $newCameraModel !== "" || $newCameraNumber != "") {
    -						$msg = "If you change <b>Camera Type</b>, <b>Camera Model</b>,";
    -						$msg .= " or <b>Camera Number</b>  you cannot change anything else.";
    -						$status->addMessage($msg, 'danger', false);
    +			if ( $ok && ($changesMade || $fromConfiguration) ) {
    +				if ($nonCameraChanges !== "" || $fromConfiguration) {
    +					if ($cameraChanged && $nonCameraChanges !== "") {
    +						$msg = "If you change <b>Camera Type</b> or <b>Camera Model</b>";
    +						$msg .= " you cannot change anything else.";
    +						$msg .= "<br>You also changed: $nonCameraChanges.";
    +						$status->addMessage($msg, 'danger');
     						$ok = false;
     					} else {
    -						// Keep track of the last time the file changed.
    -						// If we end up not updating the file this will be ignored.
    -						$lastChanged = date('Y-m-d H:i:s');
    -						$settings[$lastChangedName] = $lastChanged;
    -						$content = json_encode($settings, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
    -						// updateFile() only returns error messages.
    -						$msg = updateFile($settings_file, $content, "settings", true);
    -						if ($msg === "")
    -							$msg = "Settings saved";
    -						else
    -							$ok = false;
    +						if ($numSettingsChanges > 0 || $fromConfiguration) {
    +							// Keep track of the last time the file changed.
    +							// If we end up not updating the file this will be ignored.
    +							$lastChanged = date('Y-m-d H:i:s');
    +							$settings_array[$lastChangedName] = $lastChanged;
    +							if ($fromConfiguration) {
    +								$restartRequired = true;
    +								unset($settings_array[$endSetting]);
    +							}
    +							$content = str_replace(".0,", ",", json_encode($settings_array, $mode));
    +if ($debug) {
    +	echo "<br><br>Updating $settings_file, numSettingsChanges = $numSettingsChanges";
    +//	echo "<br>settings_array['longitude']=${settings_array['longitude']}";
    +	echo "<pre>"; var_dump($settings_array); echo "</pre>";
    +	echo "<pre>"; var_dump($content); echo "</pre>";
    +}
    +							// updateFile() only returns error messages.
    +							$msg = updateFile($settings_file, $content, "settings", true);
    +							echo '<script>console.log("Updated ' . "$settings_file, msg=$msg" . '");</script>';
    +							if ($msg === "") {
    +								if ($numSettingsChanges > 0) {
    +									$msg = "$numSettingsChanges setting";
    +									if ($numSettingsChanges > 1)
    +										$msg .= "s";
    +									$msg .= " changed.";
    +								} else {	# configuration needed and no changes made.
    +									$msg = "Configuration saved and timestamp updated.";
    +								}
    +								$needsConfiguration = false;
    +								$reReadSettings = true;
    +							} else {
    +								$msg = "Failed to update settings in '$settings_file': $msg";
    +								$status->addMessage($msg, 'danger');
    +								$ok = false;
    +							}
    +						}
    +						if ($ok && $numSourceChanges > 0) {
    +							// Now save the settings from the source files that changed.
    +							foreach($sourceFilesChanged as $fileName) {
    +								$content = json_encode(getSourceArray($fileName), $mode);
    +if ($debug) {
    +	echo "<br>Updating $fileName, numSourceChanges = $numSourceChanges";
    +	echo "<pre>"; var_dump($content); echo "</pre>";
    +}
    +								$msg = updateFile($fileName, $content, "source_settings", true);
    +// echo "<script>console.log('Updated $fileName');</script>";
    +								if ($msg === "") {
    +									$msg = "Settings in $fileName saved.";
    +									$status->addMessage($msg, 'info');
    +								} else {
    +									$msg = "Failed to update settings in '$fileName': $msg";
    +									$status->addMessage($msg, 'danger');
    +									$ok = false;
    +								}
    +							}
    +						}
     					}
     				} else {
    +					$msg = "";
     					if ($newCameraType !== "") {
    +						if ($msg !== "") $msg = "<br>$msg";
     						if ($refreshingCameraType)
    -							$msg .= "<b>Camera Type</b> $newCameraType refreshed";
    +							$msg .= "<b>Camera Type</b> $newCameraType refreshed.";
     						else
    -							$msg .= "<b>Camera Type</b> changed to <b>$newCameraType</b>";
    +							$msg .= "<b>Camera Type</b> changed to <b>$newCameraType</b>.";
     					}
     					if ($newCameraModel !== "") {
     						if ($msg !== "") $msg = "<br>$msg";
    -						$msg .= "<b>Camera Model</b> changed to <b>$newCameraModel</b>";
    +						$msg .= "<b>Camera Model</b> changed to <b>$newCameraModel</b>.";
     					}
     					if ($newCameraNumber !== "") {
     						if ($msg !== "") $msg = "<br>$msg";
    -						$msg .= "<b>Camera Number</b> changed to <b>$newCameraNumber</b>";
    +						$msg .= "<b>Camera Number</b> changed to <b>$newCameraNumber</b>.";
     					}
     
    -					if ($msg === "")
    -						$msg = "No settings changed (but timestamp updated)";
    +					if ($msg !== "")
    +						$status->addMessage($msg, 'info');
     				}
     			}
     
     			if ($ok) {
    -				// 'restart' is a checkbox: if check, it returns 'on', otherwise nothing.
    -				$doingRestart = getVariableOrDefault($_POST, 'restart', false);
    -				if ($doingRestart === "on") $doingRestart = true;
    -
    -				if ($changes !== "") {
    -					// This must run with different permissions so makeChanges.sh can
    -					// write to the allsky directory.
    +				if (! $changesMade && ! $fromConfiguration) {
    +					$msg = "No settings changed.  Nothing updated.";
    +					$status->addMessage($msg, 'warning');
    +					$msg = "";
    +				} else if ($changes !== "") {
     					$moreArgs = "";
    -					if ($doingRestart)
    -						$moreArgs .= " --restarting";
     					if ($newCameraType !== "") {
     						$moreArgs .= " --cameraTypeOnly";
     					}
     
    -					$CMD = "sudo --user=" . ALLSKY_OWNER;
    -					$CMD .= " " . ALLSKY_SCRIPTS . "/makeChanges.sh $debugArg $moreArgs $changes";
    -					# Let makeChanges.sh display any output
    -					echo '<script>console.log("Running: ' . $CMD . '");</script>';
    -					// false = don't add anything to the message
    +					// This must run with different permissions so makeChanges.sh can
    +					// write to the allsky directory.
    +					$CMD = "sudo --user=" . ALLSKY_OWNER . " ";
    +					$CMD .= ALLSKY_SCRIPTS . "/makeChanges.sh $cmdDebugArg $moreArgs $changes";
    +
    +					# Let makeChanges.sh display any output.
    +					// false = don't add anything to the message.
     					$ok = runCommand($CMD, "", "success", false);
    +
    +					// If Allsky needs to be configured again, e.g., a new camera type/model,
    +					// stop Allsky, don't restart it.
    +					$settings_array = readSettingsFile();
    +					$reReadSettings = false;	// just re-read it, so don't need to read again
    +					if (getVariableOrDefault($settings_array, $lastChangedName, null) === null) {
    +						$msg .= "Allsky needs to be re-configured.<br>";
    +						$restartRequired = false;
    +						$stopRequired = true;
    +					} else {
    +						$msg = "";
    +					}
     				}
     
     				if ($ok) {
    -					if ($doingRestart) {
    -						$msg .= " and Allsky restarted.";
    -						// runCommand displays $msg.
    -						runCommand("sudo /bin/systemctl reload-or-restart allsky.service", $msg, "success");
    +					// The "restart" field is a checkbox.  If not checked it returns nothing.
    +					if ($restartRequired && getVariableOrDefault($_POST, 'restart', "") != "") {
    +						if ($msg !== "")
    +							$msg .= " &nbsp;";
    +						$msg .= "Allsky restarted.";
    +						// runCommand() displays $msg on success.
    +						$CMD = "sudo /bin/systemctl reload-or-restart allsky.service";
    +						if (! runCommand($CMD, $msg, "success")) {	// displays $msg on success.
    +							$status->addMessage("Unable to restart Allsky.", 'warning');
    +						}
    +
    +					} else if ($stopRequired) {
    +						if ($msg !== "")
    +							$msg .= " &nbsp;";
    +
    +						$msg .= "<strong>Allsky stopped waiting for a manual restart</strong>.";
    +						$CMD = "sudo /bin/systemctl stop allsky.service";
    +						if (! runCommand($CMD, $msg, "success")) {	// displays $msg on success.
    +							$status->addMessage("Unable to stop Allsky.", 'warning');
    +						}
    +
     					} else {
    -						$msg .= "; Allsky NOT restarted.";
    -						$status->addMessage($msg, 'info');
    +						if ($msg !== "") {
    +							$status->addMessage($msg, 'info');
    +						}
    +
    +						if ($changesMade || $fromConfiguration) {
    +							// Don't show the user this message - it can confuse them.
    +							$consoleMsg = "Allsky NOT restarted";
    +							if (! $restartRequired && $changesMade) {
    +								$consoleMsg .= " - no changes required it";
    +							}
    +							echo "<script>console.log('$consoleMsg');</script>";
    +						}
    +
    +						if ($restartRequired) {
    +							$msg = "Allsky needs to be restarted for your changes to take affect.";
    +							$status->addMessage($msg, 'warning');
    +						}
    +					}
    +
    +					// If there's a website let it know of the changes.
    +					// Because postData.sh can take a while to upload files,
    +					// and it's called at end of night and uploads the settings file,
    +					// only call it here for major changes.
    +					if (($twilightDataChanged || $cameraChanged || $fromConfiguration) &&
    +							($useLocalWebsite || $useRemoteWebsite)) {
    +						$CMD = "sudo --user=" . ALLSKY_OWNER . " " . ALLSKY_SCRIPTS;
    +
    +						$moreArgs = "";
    +						if (! $twilightDataChanged)
    +							$moreArgs .= " --settingsOnly";
    +						if ($cameraChanged)
    +							$moreArgs .= " --allFiles";
    +
    +						// postData.sh will output necessary messages.
    +						$cmd = "${CMD}/postData.sh --fromWebUI $cmdDebugArg $moreArgs";
    +						$worked = runCommand($cmd, "", "success", false);
    +
    +						if ($fromConfiguration) {
    +							$cmd = "${CMD}/checkAllsky.sh --fromWebUI";
    +							echo '<script>console.log("Running: ' . $cmd . '");</script>';
    +							exec("$cmd 2>&1", $result, $return_val);
    +							if ($result != null) {
    +								$result = implode("<br>", $result);
    +								// Not worth checking if the update worked.
    +								updateFile(ALLSKY_CHECK_ALLSKY_LOG, $result, "checkAllsky", true);
    +	
    +								$msg = "<div class='errorMsgBig errorMsgBox center-div center-text'>";
    +								$msg .= "Suggested changes to your settings<br>";
    +								$msg .= "</div>";
    +								$msg .= $result;
    +								$status->addMessage($msg, 'warning');
    +							}
    +						}
     					}
     				}
     
     			} else {	// not $ok
    -				$status->addMessage("Settings NOT saved due to errors above.", 'info', false);
    +				$status->addMessage("Settings NOT saved due to errors above.", 'info');
    +			}
    +
    +			if ($reReadSettings) {
    +				$settings_array = readSettingsFile();
     			}
     		} else {
     			$status->addMessage('Unable to save settings - session timeout.', 'danger');
    @@ -226,74 +628,118 @@ function DisplayAllskyConfig(){
     
     	if (isset($_POST['reset_settings'])) {
     		if (CSRFValidate()) {
    -			$settings = array();
    +			$settings_array = array();
    +			$sourceFilesChanged = array();
    +			$sourceFilesContents = array();
     			foreach ($options_array as $option){
    -				$key = $option['name'];
    -				$value = getVariableOrDefault($option, 'default', null);
    -				if ($value !== null) $settings[$key] = $value;
    +				$name = $option['name'];
    +				$default = getVariableOrDefault($option, 'default', null);
    +				if ($default !== null) {
    +					$logicalType = getLogicalType(getVariableOrDefault($option, 'type', "text"));
    +					$default = setValue($name, $default, $logicalType);
    +
    +					$s = getVariableOrDefault($option, 'source', null);
    +					if ($s !== null) {
    +						$fileName = getFileName($s);
    +						$sourceFilesChanged[$fileName] = $fileName;
    +						// Multiple settings will likely have the same source file.
    +						$sourceFilesContents[$fileName] = &getSourceArray($fileName);
    +						$sourceFilesContents[$fileName][$name] = $default;
    +					} else {
    +						$settings_array[$name] = $default;
    +					}
    +				}
     			}
    -			$content = json_encode($settings, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_NUMERIC_CHECK);
    +
    +			// Update the settings file then any source files.
    +			$content = json_encode($settings_array, $mode);
     			$msg = updateFile($settings_file, $content, "settings", true);
    -			if ($msg === "")
    +			if ($msg === "") {
     				$status->addMessage("Settings reset to default", 'info');
    -			else
    +
    +				foreach($sourceFilesChanged as $fileName) {
    +					$content = json_encode($sourceFilesContents[$fileName], $mode);
    +					$msg = updateFile($fileName, $content, "source_settings", true);
    +					if ($msg !== "") {
    +						$status->addMessage("Failed to reset settings in '$fileName': $msg", 'danger');
    +					}
    +				}
    +
    +				// The settings file changed so re-read it.
    +				$settings_array = readSettingsFile();
    +			} else {
     				$status->addMessage("Failed to reset settings: $msg", 'danger');
    +			}
     		} else {
     			$status->addMessage('Unable to reset settings - session timeout', 'danger');
     		}
     	}
     
    -	$errorMsg = "ERROR: Unable to process settings file '$settings_file'.";
    -	$settings_array = get_decoded_json_file($settings_file, true, $errorMsg);
    +	// If $settings_array is null it means we're being called from the Allsky Website,
    +	// so read the file.
     	if ($settings_array === null) {
    -		exit;
    +		$settings_array = readSettingsFile();
     	}
    +
     	$cameraType = getVariableOrDefault($settings_array, $cameraTypeName, "");
     	$cameraModel = getVariableOrDefault($settings_array, $cameraModelName, "");
     
    -	check_if_configured($page, "settings");
    +	check_if_configured($page, "settings");	// Calls addMessage() on error
     
    -if ($formReadonly != "readonly") {
    -	$settingsDescription = "";
    -}
    +	if ($formReadonly != "readonly") $settingsDescription = "";
     ?>
    -  <div class="row">
    -	<div class="col-lg-12">
    -		<div class="panel panel-primary">
    +
    +<div class="row"> <div class="col-lg-12"> <div class="panel panel-primary">
     <?php
     	if ($formReadonly == "readonly") {
     		$x = "(READ ONLY) &nbsp; &nbsp; ";
     	} else {
    -		$x = "<i class='fa fa-camera fa-fw'></i>";
    +		$x = "<i class='fa fa-camera fa-fw'></i> ";
     	}
    +	echo "<div class='panel-heading'>$x Allsky Settings for &nbsp;<b>$cameraType $cameraModel</b></div>";
    +	echo "<div class='panel-body' style='padding: 5px;'>";
    +	if ($formReadonly != "readonly") {
    +		echo "<p id='messages'>";
    +			if ($status->isMessage()) echo $status->showMessages();
    +		echo "</p>";
    +		echo "<form method='POST' action='$ME?_ts=" . time() . " name='conf_form'>";
     ?>
    -		<div class="panel-heading"><?php echo $x; ?> Allsky Settings for &nbsp; <b><?php echo "$cameraType $cameraModel"; ?></b></div>
    -
    -		<div class="panel-body" style="padding: 5px;">
    -<?php if ($formReadonly != "readonly")
    -			echo "<p id='messages'>" . $status->showMessages() . "</p>";
    -?>
    -
    -		<form method="POST" action="<?php echo "$ME?_ts=" . time(); ?>" name="conf_form">
    -
    -<?php
    -if ($formReadonly != "readonly") { ?>
    -	<div class="sticky">
    -		<input type="submit" class="btn btn-primary" name="save_settings" value="Save changes">
    -		<input type="submit" class="btn btn-warning" name="reset_settings"
    -			value="Reset to default values"
    -			onclick="return confirm('Really RESET ALL VALUES TO DEFAULT??');">
    -		<div title="UNcheck to only save settings without restarting Allsky" style="line-height: 0.3em;">
    -			<br>
    -			<input type="checkbox" name="restart" checked> Restart Allsky after saving changes?
    -			<br><br>&nbsp;
    +		<div class="sticky settings-nav">
    +			<div class="container-fluid">
    +				<div class="row">
    +					<div class="col-md-11 col-sm-11 col-xs-11">
    +						<button type="submit" class="btn btn-primary" name="save_settings" title="Save changes">
    +							<i class="fa-solid fa-floppy-disk"></i> Save changes
    +						</button>
    +						<button type="submit" class="btn ml-3 btn-warning" name="reset_settings" title="Reset to default values" id="settings-reset">
    +							<i class="fa-solid fa-rotate-left"></i> Reset to default values
    +						</button>
    +					</div>
    +					
    +					<div class="col-md-1 col-sm-1 col-xs-1">
    +						<button type="button" class="<?php if (!$hideHeaderBodies) { echo("hidden ") ;}?>btn btn-primary ml-5 settings-expand pull-right" id="settings-all-control" title="Expand/Collapse all settings">
    +							<?php echo $showIcon ?>
    +						</button>
    +					</div>
    +				</div>
    +				<div class="row">
    +					<div class="col-md-12">
    +						<div title="Uncheck to only save settings without restarting Allsky" class="mt-4">
    +							<input type="checkbox" name="restart" value="true" checked>
    +							<span class="ml-2">Restart Allsky after saving changes, if needed?</span>
    +						</div>						
    +					</div>
    +				</div>
    +			</div>
     		</div>
    -	</div>
    -	<button onclick="topFunction(); return false;" id="backToTopBtn" title="Go to top of page">Top</button>
    -<?php } ?>
    +		<button id="backToTopBtn" type="button" title="Go to top of page">Top</button>
    +<?php
    +	}
     
    -		<input type="hidden" name="page" value="<?php echo "$page"; ?>">
    -		<?php CSRFToken();
    +CSRFToken();
    +		echo "<input type='hidden' name='page' value='$page'>\n";
    +		if ($needsConfiguration)
    +			echo "<input type='hidden' name='fromConfiguration' value='true'>\n";
     
     		if ($formReadonly == "readonly") {
     			$readonlyForm = "readonly disabled";	// covers both bases
    @@ -304,29 +750,56 @@ function DisplayAllskyConfig(){
     		$numMissing = 0;
     		$numMissingHasDefault = 0;
     		$missingSettingsHasDefault = "";
    -		echo "<table border='0'>";
    +		$missingSettings = "";
    +		$sourceFiles = array();
    +		$sourceFilesContents = array();
    +		echo "<table border='0' width='100%'>";
    +			$inHeader = false;
    +			$onHeader = 0;
    +
    +			if ($hideHeaderBodies) {
    +?>
    +				<script lang="javascript">
    +				function showHeader(headerNum) {
    +					var header = document.getElementById('header' + headerNum);
    +					var h = document.getElementById('h' + headerNum);
    +					show(headerNum, header, h);
    +				}
    +				function show(headerNum, header, h) {
    +					header.style.display = "table-row";
    +					h.title = "Click to hide";
    +					h.innerHTML = "<?php echo "$hideIcon" ?>";
    +				}
    +				</script>
    +<?php
    +			}
    +
     			foreach($options_array as $option) {
     				$name = $option['name'];
    +if (false && $debug) { echo "<br>Option $name"; }
    +				if ($name === $endSetting) continue;
     
    -				$type = getVariableOrDefault($option, 'type', "");	// should be a type
    -				if ($type == "header") {
    -					$value = "";
    -					$OLDvalue = "";
    -				} else {
    -					$default = getVariableOrDefault($option, 'default', "");
    -					$default = str_replace("'", "&#x27;", $default);
    +				$type = getVariableOrDefault($option, 'type', null);
    +				if ($type === null) {
    +					$msg = "INTERNAL ERROR: Field '$name' has no type; ignoring";
    +					$status->addMessage($msg, 'danger');
    +					continue;
    +				}
     
    -					// Allow single quotes in values (for string values).
    -					// &apos; isn't supported by all browsers so use &#x27.
    -					$value = getVariableOrDefault($settings_array, $name, $default);
    -					$value = str_replace("'", "&#x27;", $value);
    -					$OLDvalue = $value;
    +				$logicalType = getLogicalType($type);
    +				if (substr($type, 0, 7) === "select_") {
    +					$type = "select";
     				}
     
     				// Should this setting be displayed?
    -				$display = getVariableOrDefault($option, 'display', true);
    -				if (! $display && $type != "header") {
    +				$display = toBool(getVariableOrDefault($option, 'display', "true"));
    +				if (toBool(getVariableOrDefault($option, 'settingsonly', "false"))) {
    +					$display = false;
    +				}
    +				$isHeader = substr($logicalType, 0, 6) === "header";
    +				if (! $display && ! $isHeader) {
     					if ($formReadonly != "readonly") {
    +						$value = getVariableOrDefault($settings_array, $name, "");
     						// Don't display it, but if it has a value, pass it on.
     						echo "\n\t<!-- NOT DISPLAYED -->";
     						echo "<input type='hidden' name='$name' value='$value'>";
    @@ -334,33 +807,121 @@ function DisplayAllskyConfig(){
     					continue;
     				}
     
    -				$minimum = getVariableOrDefault($option, 'minimum', "");
    -				$maximum = getVariableOrDefault($option, 'maximum', "");
    +				if ($isHeader) {
    +					$value = "";
    +					$default = "";
    +				} else {
    +					$default = getVariableOrDefault($option, 'default', "");
    +					if ($default !== "" && $logicalType === "text")
    +						$default = str_replace("'", "&#x27;", $default);
    +
    +					$s = getVariableOrDefault($option, 'source', null);
    +					if ($s !== null) {
    +						if ($formReadonly) {
    +							// Don't show variables in other files since they
    +							// may contain private information.
    +							continue;
    +						}
    +
    +						$fileName = getFileName($s);
    +						$source_array = &getSourceArray($fileName);
    +if ($debug) { echo "<br>&nbsp; &nbsp; &nbsp; name=$name, fileName=$fileName"; }
    +						if ($source_array === null) {
    +							continue;
    +						}
    +						$value = getVariableOrDefault($source_array, $name, null);
    +if ($debug) { echo "<br>&nbsp; &nbsp; &nbsp; value=$value"; }
    +					} else {
    +						$value = getVariableOrDefault($settings_array, $name, null);
    +					}
    +
    +					// In read-only mode, getVariableOrDefault() returns booleans differently.
    +					// A 0 or 1 is returned.
    +					if ($logicalType === "boolean" && $formReadonly == "readonly") {
    +						if ($value === null || $value == 0) {
    +							$value = "false";
    +						} else {
    +							$value = "true";
    +						}
    +					}
    +
    +					if ($value === null) {
    +						$value = "";
    +					} else if ($logicalType === "text") {
    +						// Allow single quotes in values (for string values).
    +						// &apos; isn't supported by all browsers so use &#x27.
    +						$value = str_replace("'", "&#x27;", $value);
    +					}
    +				}
    +
     				$label = getVariableOrDefault($option, 'label', "");
     
    -				if ($type != "header") {
    -					$optional = getVariableOrDefault($option, 'optional', false);
    -					if ($value === "" && ! $optional) {
    -						if ($default === "") {
    +				if (! $isHeader) {
    +					// Do error checking of values in settings file
    +					// except for any settings already checked.
    +
    +					$optional = toBool(getVariableOrDefault($option, 'optional', "false"));
    +					$minimum = getVariableOrDefault($option, 'minimum', "");
    +					$maximum = getVariableOrDefault($option, 'maximum', "");
    +					$shortMsg = getVariableOrDefault($error_array_short, $name, "");
    +
    +					if ($shortMsg == "" && $value !== "") {
    +//x echo "<br>=== Checking $name: value=$value, type=${type_array[$name]}";
    +						$e = checkType($name,
    +								$value,
    +								$value,
    +								$option['label'],
    +								$type,
    +								$shortMsg);
    +						if ($e != "") {
    +//x echo "<br>&nbsp; &nbsp; &nbsp; e=$e, shortMsg=$shortMsg";
    +							$numErrors++;
    +							$error_array[$name] = $e;
    +							$error_array_short[$name] = $shortMsg;
    +							// All these errors are in the settings file
    +							$error_array_source[$name] = "db";
    +						}
    +					}
    +
    +					if ($shortMsg !== "" || ($value === "" && ! $optional)) {
    +
    +						if ($shortMsg !== "") {
    +							$warning_class = "alert-danger";
    +							$warning_msg = "<span class='errorMsg'>";
    +							$whereFrom = getVariableOrDefault($error_array_source, $name, "");
    +							if ($whereFrom === "user")
    +								$warning_msg .= "You entered an invalid entry: ";
    +							else
    +								$warning_msg .= "This field is invalid: ";
    +							$warning_msg .= "$shortMsg</span><br>";
    +
    +						} else if ($default === "") {
     							$numMissing++;
     							if ($missingSettings == "") {
    -								$missingSettings = "$label";
    +								$missingSettings = "&nbsp; &nbsp; $bullet ";
     							} else {
    -								$missingSettings .= ", $label";
    +								$missingSettings .= "<br>&nbsp; &nbsp; $bullet ";
     							}
    +							$missingSettings .= formatSettingName($label);
     							$warning_class = "alert-danger";
    -							$warning_msg = "<span style='color: red'>This field cannot be empty.</span><br>";
    +							$warning_msg = "<span class='errorMsg'>This field cannot be empty.</span><br>";
    +
     						} else {
     							// Use the default but let the user know.
     							$value = $default;
     							$numMissingHasDefault++;
     							if ($missingSettingsHasDefault == "") {
    -								$missingSettingsHasDefault = "$label";
    +								$missingSettingsHasDefault = "&nbsp; &nbsp; $bullet ";
     							} else {
    -								$missingSettingsHasDefault .= ", $label";
    +								$missingSettingsHasDefault .= "<br>&nbsp; &nbsp; $bullet ";
     							}
    -							$warning_class = "alert-danger";
    -							$warning_msg = "<span style='color: red'>This field was empty but set to default.</span><br>";
    +							$missingSettingsHasDefault .= formatSettingName($label);
    +							$warning_class = "alert-warning";
    +							$warning_msg = "<span class='errorMsg'>This field was empty but set to the default.</span><br>";
    +						}
    +						if ($onHeader > 0) {
    +							// Make sure the missing setting's section is displayed.
    +							echo "<script>showHeader($onHeader);</script>";
     						}
     					} else {
     						$warning_class = "";
    @@ -374,25 +935,97 @@ function DisplayAllskyConfig(){
     				// a wide input box on the top row spanning the 2nd and 3rd columns,
     				// and the description on the bottom row in the 3rd column.
     				// This way, all descriptions are in the 3rd column.
    -				if ($type !== "widetext" && $type != "header") $class = "rowSeparator";
    +				if ($type !== "widetext" && ! $isHeader) $class = "rowSeparator";
     				else $class="";
     				echo "\n";	// to make it easier to read web source when debugging
     
     				// Put some space before and after headers.  This next line is the "before":
    -				if ($type == "header") {
    +				if ($type == "header-tab") {
    +// TODO: placeholder for code to create a new tab
    +					continue;
    +
    +				} else if ($type == "header") {
     					// Not sure how to display the header with a background color with 10px
     					// of white above and below it using only one <tr>.
    -					echo "<tr style='height: 10px;'><td colspan='3'></td></tr>";
    -					echo "<tr class='rowSeparator'>";
    -						echo "<td colspan='3' class='settingsHeader' style='padding: 8px 0px;'>$description</td>";
    +					if ($hideHeaderBodies) {
    +						if ($inHeader) echo "</tbody>";
    +						else $inHeader = true;
    +					}
    +
    +					echo "\n\t<tr class='headingRow'>";
    +					if ($hideHeaderBodies) {
    +						$onHeader++;
    +						echo "<td colspan='3'>";
    +						echo "<table border=0 class='settingsHeader'>";
    +						echo "<tbody class='headingRowPadding'>";
    +						echo "<tr>";
    +						echo "<td class='headingToggle' title='Click to expand'>";
    +						echo "<span id='h$onHeader' class='setting-header-toggle' data-settinggroup='$onHeader'>$showIcon</span>";
    +						echo "</td>";
    +						echo "<td class='headingTitle'>$label</td>";
     						echo "</tr>";
    -					echo "<tr class='rowSeparator' style='height: 10px;'><td colspan='3'></td></tr>";
    +						echo "</tbody>";
    +						echo "</table>";
    +						echo "</td>";
    +					} else {
    +						echo "<td colspan='3' class='settingsHeader headingRowPadding'>";
    +						echo "$label";
    +						echo "</td>";
    +					}
    +					echo "</tr>";
    +
    +					if ($hideHeaderBodies)
    +						echo "<tbody style='display: none' id='header$onHeader' class='settings-header'>";
    +
    +					continue;
    +
    +				} else if ($type == "header-sub") {
    +					echo "<tr style='height: 5x;'>";
    +						echo "<td colspan='3'></td>";
    +					echo "</tr>";
    +					echo "\n\t<tr>";
    +						echo "<td colspan='3'><div class='subSettingsHeader'>$label</div></td>";
    +					echo "</tr>";
    +					echo "\n\t<tr class='rowSeparator' style='height: 5x;'>";
    +						echo "<td colspan='3'></td>";
    +					echo "</tr>";
    +					continue;
    +
    +				} else if ($type == "header-column") {
    +					echo "<tr  style='height: 10x;'>";
    +						echo "<td colspan='3'></td>";
    +					echo "</tr>";
    +					echo "<tr class='columnHeader'>";
    +						$columns = explode(",", $label);
    +						foreach ($columns as $col) {
    +							echo "<td style='margin: 0;'>$col</td>";
    +						}
    +					echo "</tr>";
    +					echo "<tr class='rowSeparator' style='height: 10x;'>";
    +						echo "<td colspan='3'></td>";
    +					echo "</tr>";
    +					continue;
    +
     				} else {
     					echo "<tr class='form-group $class $warning_class' style='margin-bottom: 0px;'>";
    +					$action = getVariableOrDefault($option, 'action', "none");
    +					if ($action == "restart") {
    +						$popupExtraMsg = "RESTART REQUIRED";
    +					} else if ($action == "reload") {
    +// TODO: when "reload" is implemented change RESTART to RELOAD.  Or, always say RESTART?  Will the user know the difference?
    +						$popupExtraMsg = "RESTART REQUIRED";
    +					} else if ($action == "stop") {
    +						$popupExtraMsg = "ALLSKY WILL STOP AFTER\nCHANGING THIS SETTING";
    +					} else {
    +						$popupExtraMsg = "";
    +					}
    +
     					// Show the default in a popup
    -					if ($type == "boolean") {
    -						if ($default == "0") $default = "No";
    -						else $default = "Yes";
    +					if ($logicalType == "boolean") {
    +						// Boolean values are strings: "true" or "false".
    +						if ($default == "true") $default = "Yes";
    +						else $default = "No";
    +
     					} elseif ($type == "select") {
     						foreach($option['options'] as $opt) {
     							$val = getVariableOrDefault($opt, 'value', "?");
    @@ -401,58 +1034,69 @@ function DisplayAllskyConfig(){
     							break;
     						}
     					}
    -					$popup = "";
    -					if ($default !== "") $popup .= "Default=$default";
    +					if ($default == "") $popup="No default";
    +					else $popup = "Default=$default";
     					if ($minimum !== "") $popup .= "\nMinimum=$minimum";
    -					if ($maximum !== "") $popup .= "\nMaximum=$maximum";
    -					if ($type == "integer" || $type == "percent") $popup .= "\nWhole numbers only";
    -					if ($type == "float") $popup .= "\nFractions allowed";
    -
    -					if ($type == "widetext") $span="rowspan='2'";
    -					else $span="";
    -					echo "\n\t<td $span valign='middle' style='padding: 2px 0px'>";
    -					echo "<label class='WebUISetting' style='padding-right: 3px;'>$label</label>";
    +					if ($maximum !== "") {
    +						if ($maximum === "none") $popup .= "\nNo maximum";
    +						else $popup .= "\nMaximum=$maximum";
    +					}
    +
    +					$rspan="";
    +					$cspan="";
    +
    +					if ($logicalType == "integer") {
    +						$popup .= "\nWhole numbers only";
    +					} else if ($logicalType == "float") {
    +						$popup .= "\nFractions allowed";
    +					} else if ($type == "widetext") {		// check $type, not $logicalType
    +						$rspan="rowspan='2'";
    +						$cspan="colspan='2'";
    +					}
    +					if ($popupExtraMsg !== "") $popup .= "\n**********\n$popupExtraMsg";
    +
    +					echo "\n\t<td $rspan valign='middle' style='padding: 2px 0px'>";
    +						echo "<label class='WebUISetting' style='padding-right: 3px;'>$label</label>";
     					echo "</td>";
     
     					if ($type == "widetext") {
    -						$span="colspan='2'";
     						$style="padding: 5px 3px 7px 8px;";
     					} else {
    -						$span="";
     						// Less on top side to even out with drop-shadow on bottom.
     						// Ditto for left side with shadow on right.
     						$style="padding: 5px 5px 7px 8px;";
     					}
     
    -					echo "\n\t<td $span valign='middle' style='$style' align='center'>";
    -					// The popup gets in the way of seeing the value a little.
    -					// May want to consider having a symbol next to the field
    -					// that has the popup.
    +					if (toBool(getVariableOrDefault($option, 'readonly', "false")))
    +						$readonly = "readonly";
    +					else
    +						$readonly = "";
    +					
    +					echo "\n\t<td $cspan valign='middle' style='$style' align='center'>";
    +					// TODO: The popup can get in the way of seeing the value a little.
    +					// May want to consider having a symbol next to the field that has the popup.
     					echo "<span title='$popup'>";
     // TODO: add percent sign for "percent"
    -					if ($type == "text" || $type == "integer" || $type == "float" || $type == "percent" || $type == "readonly"){
    -						if ($type == "readonly") {
    -							$readonly = "readonly";
    -							$t = "text";
    -						} else {
    -							$readonly = "";
    -							// Browsers put the up/down arrows for numbers which moves the
    -							// numbers to the left, and they don't line up with text.
    -							// Plus, they don't accept decimal points in "float".
    -							if ($type == "integer" || $type == "float" || $type == "percent")
    +					if (in_array($type, ["text", "password", "color", "integer",
    +								"float", "percent"])) {
    +						// Browsers put the up/down arrows for numbers which moves the
    +						// numbers to the left, and they don't line up with text.
    +						// Plus, they don't accept decimal points in "float".
    +						// So, display numbers as text.
    +						if ($type == "integer" || $type == "float" ||
    +							$type == "color" || $type == "percent") {
     								$type = "text";
    -							$t = $type;
     						}
    -						echo "\n\t<input $readonly class='form-control boxShadow settingInput ' type='$t'" .
    -							" $readonlyForm name='$name' value='$value'" .
    -							" style='padding: 0px 3px 0px 0px; text-align: right;' >";
    +						echo "\n\t\t<input class='form-control boxShadow settingInput settingInputTextNumber'" .
    +							" type='$type' $readonly $readonlyForm name='$name' value='$value' >";
    +
     					} else if ($type == "widetext"){
    -						echo "\n\t<input class='form-control boxShadow' type='text'" .
    -							" $readonlyForm name='$name' value='$value'" .
    -						   	" style='padding: 6px 5px;'>";
    +						echo "\n\t\t<input class='form-control boxShadow settingInputWidetext'" .
    +							" type='text' $readonlyForm name='$name' value='$value'>";
    +
     					} else if ($type == "select"){
    -						echo "\n\t<select class='form-control boxShadow settingInput' name='$name' title='Select an item'" .
    -						   	" $readonlyForm style='text-align: right; padding: 0px 3px 0px 0px;'>";
    +						echo "\n\t\t<select class='form-control boxShadow settingInput settingInputSelect'" .
    +							" $readonlyForm name='$name'>";
     						foreach($option['options'] as $opt){
     							$val = getVariableOrDefault($opt, 'value', "?");
     							$lab = getVariableOrDefault($opt, 'label', "?");
    @@ -463,63 +1107,98 @@ function DisplayAllskyConfig(){
     							}
     						}
     						echo "</select>";
    +
     					} else if ($type == "boolean"){
    -						echo "\n\t<div class='switch-field boxShadow settingInput' style='margin-bottom: -3px; border-radius: 4px;'>";
    -							echo "\n\t<input id='switch_no_".$name."' class='form-control' type='radio' ".
    -								"$readonlyForm name='$name' value='0' ".
    -								($value == 0 ? " checked " : "").  ">";
    -							echo "<label style='margin-bottom: 0px;' for='switch_no_".$name."'>No</label>";
    -							echo "\n\t<input id='switch_yes_".$name."' class='form-control' type='radio' ".
    -								"$readonlyForm name='$name' value='1' ".
    -								($value == 1 ? " checked " : "").  ">";
    -							echo "<label style='margin-bottom: 0px;' for='switch_yes_".$name."'>Yes</label>";
    +						echo "\n\t\t<div class='switch-field boxShadow settingInput settingInputBoolean'>";
    +							echo "\n\t\t<input id='switch_no_$name' class='form-control' type='radio' ".
    +								"$readonlyForm name='$name' value='false' ".
    +								($value == "false" ? " checked " : "").  ">";
    +							echo "<label style='margin-bottom: 0px;' for='switch_no_$name'>No</label>";
    +							echo "\n\t\t<input id='switch_yes_$name' class='form-control' type='radio' ".
    +								"$readonlyForm name='$name' value='true' ".
    +								($value == "true" ? " checked " : "").  ">";
    +							echo "<label style='margin-bottom: 0px;' for='switch_yes_$name'>Yes</label>";
     						echo "</div>";
     					}
     					echo "</span>";
    +					echo "\n\t</td>";
     
    -					// Track current values so we can determine what changed.
    -					if ($formReadonly != "readonly")
    -						echo "\n\t<input type='hidden' name='OLD_$name' value='$OLDvalue'>";
    -
    -					echo "</td>";
    -					if ($type == "widetext")
    -						echo "</tr><tr class='rowSeparator'><td></td>";
    +					if ($type == "widetext") {
    +						echo "\n</tr>";
    +						echo "\n<tr class='rowSeparator'>";
    +							echo "\n\t<td></td>";
    +					}
     					echo "\n\t<td style='padding-left: 10px;'>$warning_msg$description</td>";
    +
    +					echo "\n</tr>";
     				}
    -				echo "</tr>";
    -			 }
    +			}
    +			if ($inHeader) echo "</tbody>";
     		echo "</table>";
     
    -		$needToShowMessages = false;
    -		if ($numMissing > 0 && $formReadonly != "readonly") {
    -			$needToShowMessages = true;
    -			$msg = "ERROR: $numMissing required field" . ($numMissing === 1 ? " is" : "s are");
    -			$msg .= " missing (<strong>$missingSettings</strong>) - see highlighted entries below.";
    -			$status->addMessage($msg, 'danger');
    -		}
    -		if ($numMissingHasDefault > 0 && $formReadonly != "readonly") {
    -			$needToShowMessages = true;
    -			$msg = "WARNING: $numMissingHasDefault required field" . ($numMissingHasDefault === 1 ? " is" : "s are");
    -			$msg .= " missing (<strong>$missingSettingsHasDefault</strong>) but replaced by the default - see highlighted entries below. You MUST click the 'Save changes' button below.";
    -			$status->addMessage($msg, 'danger');
    +		if ($fromConfiguration) {
    +			// Hide the message about "Allsky must be configured..."
    +?>
    +			<script>
    +			var p = document.getElementById('mustConfigure').parentElement;
    +			var gp = p.parentElement;
    +			if (gp != null) p = gp;
    +			p.style.display = 'none';
    +			</script>
    +<?php
     		}
    -		if ($needToShowMessages) {
    -		?>
    +
    +		if ($formReadonly != "readonly") {
    +			$msg = "";
    +			if ($numErrors > 0) {
    +				$msg .= "<strong>";
    +				$msg .= "ERROR: invalid/missing field" . ($numErrors+$numMissing > 1 ? "s" : "") . ":";
    +				$msg .= "</strong>";
    +				foreach ($error_array as $errorName => $errorMsg) {
    +					$msg .= "<br>&nbsp; &nbsp; $bullet $errorMsg";
    +				}
    +			}
    +			if ($numMissing > 0) {
    +				$msg .= "<br><strong>$missingSettings</strong> is missing";
    +			}
    +			if ($msg != "") {
    +				// Combine invalid and missing fields since they are both errors.
    +				$status->addMessage($msg, 'danger');
    +			}
    +
    +			if ($numMissingHasDefault > 0) {
    +				$msg = "<strong>";
    +				$msg .= "Required field" . ($numMissingHasDefault === 1 ? " is" : "s are");
    +				$msg .= " missing but replaced by the default:";
    +				$msg .= "</strong>";
    +				$msg .= "<br><strong>$missingSettingsHasDefault</strong>";
    +				$status->addMessage($msg, 'warning');
    +			}
    +
    +			if ($status->isMessage()) {
    +				$status->addMessage("<strong>See the highlighted entries below.</strong>", 'info');
    +			}
    +?>
     			<script>
     				var messages = document.getElementById("messages");
    -				// Call showMessages() with the 2nd (escape) argument of true so it escapes single quotes.
    +				var inner = messages.innerHTML;
    +				// Call showMessages() with the 2nd (escape) argument of "true" so
    +				// it escapes single quotes and deletes newlines.
     				// We then have to restore them so the html is correct.
    -				messages.innerHTML += '<?php $status->showMessages(true, true); ?>'.replace(/&apos;/g, "'");
    +				messages.innerHTML += '<?php $status->showMessages(true, true, true); ?>'
    +					.replace(/&apos;/g, "'")
    +					.replace(/&#10/g, "\n");
     			</script>
    -		<?php
    -		} 
    -		?>
    +<?php	} ?>
     
     	</form>
     </div><!-- ./ Panel body -->
    -</div><!-- /.panel-primary -->
    -</div><!-- /.col-lg-12 -->
    -</div><!-- /.row -->
    +</div><!-- /.panel-primary --> </div><!-- /.col-lg-12 --> </div><!-- /.row -->
    +
    +
     <?php
    +	if (! $formReadonly)
    +		echo '<script src="js/settings.js"></script>';
    +
     }
     ?>
    diff --git a/html/includes/authenticate.php b/html/includes/authenticate.php
    index f610fee8c..4bc37d60b 100644
    --- a/html/includes/authenticate.php
    +++ b/html/includes/authenticate.php
    @@ -1,8 +1,24 @@
     <?php
    -$user = getVariableOrDefault($_SERVER, "PHP_AUTH_USER", "");
    -$pass = getVariableOrDefault($_SERVER, "PHP_AUTH_PW", "");
    +// Default admin username and password:
    +$config = array(
    +  'admin_user' => 'admin',
    +  'admin_pass' => '$2y$10$YKIyWAmnQLtiJAy6QgHQ.eCpY4m.HCEbiHaTgN6.acNC6bDElzt.i'
    +);
     
    +// Can be overridden by what's in this file, if it exists:
    +if(file_exists(RASPI_ADMIN_DETAILS)) {
    +    if ( $auth_details = fopen(RASPI_ADMIN_DETAILS, 'r') ) {
    +      $config['admin_user'] = trim(fgets($auth_details));
    +      $config['admin_pass'] = trim(fgets($auth_details));
    +      fclose($auth_details);
    +    }
    +}
    +
    +
    +// Check login if needed.
     if ($useLogin) {
    +	$user = getVariableOrDefault($_SERVER, "PHP_AUTH_USER", "");
    +	$pass = getVariableOrDefault($_SERVER, "PHP_AUTH_PW", "");
     	$validated = ($user == $config['admin_user']) && password_verify($pass, $config['admin_pass']);
     
     	if (! $validated) {
    @@ -11,5 +27,4 @@
     	  	die ("Not authorized");
     	}
     }
    -
     ?>
    diff --git a/html/includes/configureWiFi.php b/html/includes/configureWiFi.php
    index ed9e1e887..43c7998b8 100644
    --- a/html/includes/configureWiFi.php
    +++ b/html/includes/configureWiFi.php
    @@ -2,59 +2,106 @@
     
     function DisplayWPAConfig(){
     	global $page;
    -	$status = new StatusMessages();
    +	$debug = false;
    +	$allowOpen = true;		// allow connecting to "open" SSIDs?  TODO: Any reason NOT to?
    +	$myStatus = new StatusMessages();
     
     	// Find currently configured networks
    -	exec(' sudo cat ' . RASPI_WPA_SUPPLICANT_CONFIG, $known_return);
    +	$dataFile = RASPI_WPA_SUPPLICANT_CONFIG;
    +	$cmd = "sudo cat '$dataFile'";
    +	if ($debug) echo "<br>Executing $cmd";
    +	exec($cmd, $known_out);
     
    -	$network = null;
    +	$thisNetwork = null;
     	$ssid = null;
     
     	// Process the already-configured networks.
    -	foreach($known_return as $line) {
    +	$onLine = 0;
    +	$inNetwork = false;
    +	$numNetworks = 0;
    +	foreach($known_out as $line) {
    +		$onLine++;
    +		if ($line === "") continue;
    +
    +		if ($debug) echo "<br>Line $onLine: $line";
     		if (preg_match('/network\s*=/', $line)) {
    -			$network = array('visible' => false, 'configured' => true, 'connected' => false);
    -		} elseif ($network !== null) {
    -			if (preg_match('/^\s*}\s*$/', $line)) {
    -				$networks[$ssid] = $network;
    -				$network = null;
    +		if ($debug) echo "<br>&nbsp; &nbsp; new network";
    +			$inNetwork = true;
    +			$numNetworks++;
    +			$thisNetwork = array('visible' => false, 'configured' => true, 'connected' => false);
    +		} elseif ($thisNetwork !== null) {
    +			if (preg_match('/^\s*}\s*$/', $line)) {		// end of info for this Network
    +			if ($debug) echo "<br>&nbsp; &nbsp; end of network $ssid";
    +				$networks[$ssid] = $thisNetwork;
    +				$thisNetwork = null;
     				$ssid = null;
    +				$inNetwork = false;
     			} elseif ($lineArr = preg_split('/\s*=\s*/', trim($line))) {
    -				switch(strtolower($lineArr[0])) {
    +				// The ssid and #psk keys have double quotes around the values.
    +				// The psk key does not (at least when it's an encrypted value).
    +				// The psk key may be a plain-text value or a 64-character encrypted value.
    +
    +				$key = strtolower($lineArr[0]);
    +				$value = trim($lineArr[1], '"');
    +				if ($debug) echo "<br>&nbsp; &nbsp; data line, key=${key}";
    +				switch($key) {
     					case 'ssid':
    -						$ssid = trim($lineArr[1], '"');
    +						$ssid = $value;
     						break;
     					case 'psk':
    -						if (array_key_exists('passphrase', $network)) {
    +						if (array_key_exists('passphrase', $thisNetwork)) {
     							break;
     						}
     					case '#psk':
    -						$network['protocol'] = 'WPA';
    +						if ($key === '#psk')
    +							$thisNetwork['#psk'] = $value;	// usually plain-text passphrase
    +						$thisNetwork['protocol'] = 'WPA';
     					case 'wep_key0': // Untested
    -						$network['passphrase'] = trim($lineArr[1], '"');
    +						$thisNetwork['passphrase'] = $value;
     						break;
     					case 'key_mgmt':
    -						if (! array_key_exists('passphrase', $network) && $lineArr[1] === 'NONE') {
    -							$network['protocol'] = 'Open';
    +						if (! array_key_exists('passphrase', $thisNetwork) && $value === 'NONE') {
    +							$thisNetwork['protocol'] = 'Open';
     						}
     						break;
    +
    +					default:
    +						// Except debugging, don't display this since there
    +						// are likely other keys than the ones above.
    +//						echo "<br> &nbsp; &nbsp; *** Line $onLine: Unknown key: [$key]";
    +						break;
     				}
    +			} else {
    +				// All the lines within a network entry should be   key=value
    +				$msg = "Line $onLine in $dataFile may be invalid: $line";
    +				$myStatus->addMessage($msg, "warning");
     			}
    +		} else if ($numNetworks > 0) {
    +			// The first couple lines in the file may be configuration lines,
    +			// so ignore.
    +			// Any other line inbetween network entries is invalid
    +			// and will likely cause a failure.
    +			$msg = "Line $onLine in $dataFile is out of place: $line";
    +			$myStatus->addMessage($msg, "danger");
     		}
     	}
     
     	if ( isset($_POST['client_settings']) && CSRFValidate() ) {
     		$tmp_networks = $networks;
    -		if ($wpa_file = fopen('/tmp/wifidata', 'w')) {
    +		$tmp_file = "/tmp/wifidata";
    +		if ($wpa_file = fopen($tmp_file, 'w')) {
     			// Re-create whole configuration file - don't try to only update the changed SSID.
    -			fwrite($wpa_file, 'ctrl_interface=DIR=' . RASPI_WPA_CTRL_INTERFACE . ' GROUP=netdev' . PHP_EOL);
    +			fwrite($wpa_file, 'ctrl_interface=DIR=' . RASPI_WPA_CTRL_INTERFACE);
    +			fwrite($wpa_file, ' GROUP=netdev' . PHP_EOL);
     			fwrite($wpa_file, 'update_config=1' . PHP_EOL);
     
    -// echo "<br>POST=<pre>"; print_r($_POST); echo "</pre>";
    +if ($debug) { echo "<br>POST=<pre>"; print_r($_POST); echo "</pre>"; }
     
     			foreach(array_keys($_POST) as $post) {
     				if (preg_match('/delete(\d+)/', $post, $post_match)) {
    -					unset($tmp_networks[$_POST['ssid' . $post_match[1]]]);
    +					$num = $post_match[1];
    +					$s = $_POST["ssid$num"];
    +					unset($tmp_networks[$s]);
     				} elseif (preg_match('/update(\d+)/', $post, $post_match)) {
     					// NB, at the moment, the value of protocol from the form may
     					// contain HTML line breaks
    @@ -65,11 +112,12 @@ function DisplayWPAConfig(){
     						'passphrase' => $_POST["passphrase$num"],
     						'configured' => true
     						);
    -// echo "<br>tmp_networks[$s]=<pre>"; print_r($tmp_networks[$s]); echo "</pre>";
    +if ($debug) { echo "<br>tmp_networks[$s]=<pre>"; print_r($tmp_networks[$s]); echo "</pre>"; }
     				}
     			}
     
     			$ok = true;
    +if ($debug) { echo "<br>tmp_networks=<pre>"; print_r($tmp_networks); echo "</pre>"; }
     			foreach($tmp_networks as $ssid => $network) {
     				if ($network['protocol'] === 'Open') {
     					fwrite($wpa_file, "network={".PHP_EOL);
    @@ -77,18 +125,46 @@ function DisplayWPAConfig(){
     					fwrite($wpa_file, "\tkey_mgmt=NONE".PHP_EOL);
     					fwrite($wpa_file, "}".PHP_EOL);
     				} else {
    -					$passphrase = $network['passphrase'];
    +					$pound_psk = getVariableOrDefault($network, '#psk', "");
    +					if ($pound_psk !== "")
    +						$passphrase = $pound_psk;
    +					else
    +						$passphrase = $network['passphrase'];
     					$len = strlen($passphrase);
     					if ($len >=8 && $len <= 63) {
    -						unset($wpa_passphrase);
    +						// un-encrypted passphrase - get encrypted version.
    +						unset($wpa_passphrase_out);
     						unset($line);
    -						$cmd = 'wpa_passphrase '. escapeshellarg($ssid) . ' ' . escapeshellarg($passphrase);
    -						exec($cmd, $wpa_passphrase );
    -						foreach($wpa_passphrase as $line) {
    -							fwrite($wpa_file, $line.PHP_EOL);
    +						$cmd = 'wpa_passphrase '. escapeshellarg($ssid);
    +						$cmd .= ' ' . escapeshellarg($passphrase);
    +						exec($cmd, $wpa_passphrase_out, $wpa_passphrase_return);
    +
    +						if ($wpa_passphrase_return == 0) {
    +							# This writes a complete "network={ ... }" block with #psk.
    +							foreach($wpa_passphrase_out as $line) {
    +								fwrite($wpa_file, $line.PHP_EOL);
    +							}
    +						} else {
    +							$msg = "'$cmd' failed";
    +							$myStatus->addMessage($msg, 'danger');
    +							$ok = false;
     						}
    +					} else if ($len == 64) {	// 64 means it's already encrypted
    +						fwrite($wpa_file, "network={".PHP_EOL);
    +						fwrite($wpa_file, "\tssid=\"$ssid\"".PHP_EOL);
    +						if ($pound_psk !== "")
    +							fwrite($wpa_file, "\t#psk=\"$pound_psk\"".PHP_EOL);
    +						if ($passphrase !== "")
    +							fwrite($wpa_file, "\tpsk=$passphrase".PHP_EOL);
    +						fwrite($wpa_file, "}".PHP_EOL);
    +					} else if ($len == 0) {
    +						$msg = "WPA passphrase for $ssid is required.";
    +						$myStatus->addMessage($msg, "danger");
    +						$ok = false;
     					} else {
    -						$status->addMessage("WPA passphrase for $ssid must be between 8 and 63 characters (it is $len)", "danger");
    +						$msg = "WPA passphrase for $ssid ($passphrase)";
    +						$msg .= "  is $len characters but must be between 8 and 63.";
    +						$myStatus->addMessage($msg, "danger");
     						$ok = false;
     					}
     				}
    @@ -96,49 +172,57 @@ function DisplayWPAConfig(){
     			fclose($wpa_file);
     
     			if ($ok) {
    -				system( 'sudo cp /tmp/wifidata ' . RASPI_WPA_SUPPLICANT_CONFIG, $returnval );
    +				system( "sudo cp '$tmp_file' '$dataFile'", $returnval );
     				if( $returnval == 0 ) {
     					exec('sudo wpa_cli reconfigure', $reconfigure_out, $reconfigure_return );
     					if ($reconfigure_return == 0) {
    -						$status->addMessage('Wifi settings updated successfully', 'success');
    +						$myStatus->addMessage('Wi-Fi settings updated successfully', 'success');
     						$networks = $tmp_networks;
     					} else {
    -						$status->addMessage('Wifi settings updated but cannot restart (cannot execute "wpa_cli reconfigure")', 'danger');
    +						$msg = 'Wi-Fi settings updated but cannot restart';
    +						$msg .= ' (cannot execute "wpa_cli reconfigure")';
    +						$myStatus->addMessage($msg, 'danger');
     					}
     				} else {
    -					$status->addMessage('Wifi settings failed to be updated', 'danger');
    +					$myStatus->addMessage('Wi-Fi settings failed to be updated', 'danger');
     				}
     			}
     		} else {
    -			$status->addMessage('Failed to updated wifi settings', 'danger');
    +			$myStatus->addMessage('Failed to updated Wi-Fi settings', 'danger');
     		}
     	}
     
     	// Scan for all networks.
     	exec( 'sudo wpa_cli scan' );
    -	sleep(3);
    -	exec( 'sudo wpa_cli scan_results', $scan_return );
    +	sleep(2);
    +	$cmd = 'sudo wpa_cli scan_results';
    +	exec( $cmd, $scan_return );
    +if ($debug) { echo "<br><pre>wpa_cli scan_results:<br>"; print_r($scan_return); echo "</pre>"; }
     	for( $shift = 0; $shift < 2; $shift++ ) {
     		// Skip first two header lines
     		array_shift($scan_return);
     	}
     	// display output
     	$have_multiple = false;
    -	static $note = " <span style='color: red; font-weight: bold'>*</span>";
    +	static $note = " <span style='color: red; font-weight: 900; font-size: 110%;'>*</span>";
     	// $networks contains the prior-configured SSID(s).
     	// New SSIDs are added to $networks.
    -	$num_networks = 0;
     	if (! isset($networks)) $networks = [];	// eliminates warning messages in log file
     
    -	// Walk through each scanned SSID.
    +	// Walk through each scanned network.
    +	$numScannedNetworks = 0;
    +	$noSSID = "";
    +	$onLine = 0;
     	foreach( $scan_return as $network ) {
    +		$onLine++;
     		$arrNetwork = preg_split("/[\t]+/",$network);
    -		// fields: bssid,   frequency, signal level, flags,    ssid 
    -		// fields:          channel                  protocol  ssid
    -		// fields: 0        1          2             3         4
    -		if (isset($arrNetwork[4])) {
    +		// fields:		bssid,   frequency, signal level, flags,    ssid 
    +		// fields:		         channel                  protocol
    +		// field #:		0        1          2             3         4
    +		$ssid = getVariableOrDefault($arrNetwork, 4, null);
    +		if ($ssid !== null) {
    +			$numScannedNetworks += 1;
     			$channel = ConvertToChannel($arrNetwork[1]);
    -			$ssid = $arrNetwork[4];
     			if (substr($ssid, 0, 4) == "\\x00") $ssid = "Unknown (\\x00)";
     			if (array_key_exists($ssid, $networks)) {
     				// Already configured SSID.
    @@ -148,18 +232,15 @@ function DisplayWPAConfig(){
     				// Some SSIDs may be on multiple channels in multiple bands
     				if (! isset($networks[$ssid]['channel'])) {
     					// This is the SSID that's in use.
    -// echo "<br>Existing SSD $ssid, in use";
     					$networks[$ssid]['channel'] = $channel;
     					$networks[$ssid]['times'] = 1;
     				} else {
     					$have_multiple = true;
     					// $networks[$ssid]['channel'] .= "<br>$channel";
     					$networks[$ssid]['times']++;
    -// echo "<br>Existing SSD $ssid, Occurence: " . $networks[$ssid]['times'] . ", channel=$channel";
     				}
     			} else {
     				// New SSID
    -				$num_networks += 1;
     				$networks[$ssid] = array(
     					'configured' => false,
     					'protocol' => ConvertToSecurity($arrNetwork[3]),
    @@ -170,8 +251,20 @@ function DisplayWPAConfig(){
     					'connected' => false
     				);
     			}
    +		} else {
    +			if ($noSSID === "") {
    +				$noSSID = "[$cmd] Returned no SSD on:";
    +			}
    +			$noSSID .= "\n line $onLine: $network";
     		}
     	}
    +	if ($numScannedNetworks == 0) {
    +		$myStatus->addMessage("No scanned networks found", 'warning');
    +	} else if ($noSSID !== "") {
    +		// It's common for multiple lines to not have an SSID,
    +		// so don't use addMessage().
    +		echo "<script>console.log(`$noSSID`)</script>";
    +	}
     
     	exec( 'iwconfig wlan0', $iwconfig_return );
     	foreach ($iwconfig_return as $line) {
    @@ -187,7 +280,7 @@ function DisplayWPAConfig(){
     		<div class="panel-heading"><i class="fa fa-wifi fa-fw"></i> Configure Wi-Fi</div>
     		<!-- /.panel-heading -->
     		<div class="panel-body">
    -			<?php if ($status->isMessage()) echo "<p>" . $status->showMessages() . "</p>"; ?>
    +			<?php if ($myStatus->isMessage()) echo "<p>" . $myStatus->showMessages() . "</p>"; ?>
     			<h4>Wi-Fi SSIDs</h4>
     
     			<form method="POST" action="?page=<?php echo $page ?>" name="wpa_conf_form">
    @@ -206,20 +299,17 @@ function DisplayWPAConfig(){
     				</thead>
     				<tbody>
     
    -			<?php $index = 0;
    -			if ($num_networks == 0) {
    -				echo "<p style='font-size: 150%; color: red;'>No networks found</p>";
    -			} else foreach ($networks as $ssid => $network) {
    -					$configured = getVariableOrDefault($network, 'configured', false);
    -					$connected = getVariableOrDefault($network, 'connected', false);
    -					$visible = getVariableOrDefault($network, 'visible', false);
    +			<?php
    +				$index = 0;
    +				foreach ($networks as $ssid => $network) {
    +					$configured = toBool(getVariableOrDefault($network, 'configured', "false"));
    +					$connected = toBool(getVariableOrDefault($network, 'connected', "false"));
    +					$visible = toBool(getVariableOrDefault($network, 'visible', "false"));
     					$channel = getVariableOrDefault($network, 'channel', "");
     					$times = getVariableOrDefault($network, 'times', 1);
     					$protocol = getVariableOrDefault($network, 'protocol', "");
     					$passphrase = getVariableOrDefault($network, 'passphrase', "");
     					$fullPassphrase = $passphrase;
    -					// If the passphrase is long, shorten it so it doesn't take up too much space.
    -					if (strlen($passphrase) > 10) $passphrase = substr($passphrase, 0, 10);
     
     					echo "<tr>";
     
    @@ -244,28 +334,43 @@ function DisplayWPAConfig(){
     					}
     					echo "</td>";
     
    -					echo "\n\t<td><input type='hidden' name='protocol$index' value='$protocol' />$protocol</td>";
    +					echo "\n\t<td>";
    +					if ($protocol === 'Open' && ! $allowOpen) {
    +						echo "Open $note $note";
    +					} else {
    +						echo "<input type='hidden' name='protocol$index' value='$protocol' />$protocol";
    +					}
    +					echo "</td>";
    +
    +					echo "\n\t<td>";
    +					if ($protocol === 'Open') {
    +						echo "<input type='hidden' name='passphrase$index' value='' />---";
    +					} else {
    +						echo "<input type='password'";
    +						echo " class='form-control'";
    +						echo " style='width: 7em; font-size: 80%; padding-left: 2px; padding-right: 2px;'";
    +						echo " name='passphrase$index'";
    +						echo " title='$fullPassphrase'";
    +						echo " value='$passphrase'";
    +						echo " onKeyUp='CheckPSK(this, " . '"' . "update$index" . '"' .")'>";
    +					}
    +					echo "</td>";
     
    -					if ($protocol === 'Open')
    -						echo "\n\t<td><input type='hidden' name='passphrase$index' value='' />---</td>";
    -					else
    -						echo "\n\t<td><input type='password' class='form-control' style='width: 7em; font-size: 80%; padding-left: 2px; padding-right: 2px;' name='passphrase$index' title='$fullPassphrase' value='$passphrase' onKeyUp='CheckPSK(this, " . '"' . "update$index" . '"' .")'></td>";
     					echo "\n\t<td>";
     					echo '<div class="btn-group btn-block nowrap">';
     					$buttonStyle = "style='padding-left: 3px; padding-right: 3px; width: 4em; pointer-events: auto;'";
    -					$d = ($protocol === 'Open') ? ' disabled title="Cannot add Open SSIDs" ' : '';
    -					$d="";		// TODO: Any reason NOT to allow adding Open SSIDs ?
    +					$d = ($protocol === 'Open' && ! $allowOpen) ? ' disabled title="Cannot add Open SSIDs" ' : '';
     					if ($configured) {
     						echo "<input type='submit' class='btn btn-warning' $buttonStyle value='Update' ";
    -						if ($protocol === 'Open')
    +						if ($protocol === 'Open' && ! $allowOpen)
     							echo "disabled title='Cannot update Open SSIDs' />";
     						else
     							echo "id='update$index' name='update$index' $d />";
     					} else {
     						echo "<input type='submit' class='btn btn-info' $buttonStyle value='Add' id='update$index' name='update$index' $d />";
     					}
    -					$d = $configured ? '' : ' disabled title="SSID not configured"';
    -					echo "<input type='submit' class='btn btn-danger' $buttonStyle value='Delete' name='delete$index' $d />";
    +					if ($configured)
    +						echo "<input type='submit' class='btn btn-danger' $buttonStyle value='Delete' name='delete$index'/>";
     					echo "</div>";
     					echo "</td>";
     					echo "</tr>\n";
    @@ -277,12 +382,14 @@ function DisplayWPAConfig(){
     			</form>
     		</div><!-- ./ Panel body -->
     		<div class="panel-footer">
    -			<?php if ($have_multiple)
    -				echo "$note SSID is in multiple channels and/or bands; only the first is listed above.<br>";
    +			<?php
    +				if ($have_multiple)
    +					echo "$note SSID is in multiple channels and/or bands; only the first is listed.<br>";
    +				if (! $allowOpen) {
    +					echo "$note $note WEP (insecure) access points appear as 'Open'.";
    +					echo " Allsky does not support connecting to WEP for security reasons.";
    +				}
     			?>
    -			<strong>Note,</strong>
    -			WEP access points appear as 'Open'.
    -			Allsky does not currently support connecting to WEP.
     		</div>
     		</div><!-- /.panel-primary -->
     	</div><!-- /.col-lg-12 -->
    diff --git a/html/includes/dashboard_LAN.php b/html/includes/dashboard_LAN.php
    index bde19a5d7..f40a0a5bc 100644
    --- a/html/includes/dashboard_LAN.php
    +++ b/html/includes/dashboard_LAN.php
    @@ -1,28 +1,57 @@
     <?php
     
    -function DisplayDashboard_LAN($interface) {
    +function DisplayDashboard_LAN()
    +{
    +
    +?>
    +<div class="col-lg-12">
    +	<div class="panel panel-primary">
    +		<div class="panel-heading"><i class="fa fa-network-wired fa-fw"></i> LAN Dashboard</div>
    +<?php
    +	$num_interfaces = 0;
    +
    +	$dq = '"';		// double quote
    +	$cmd = "hwinfo --network --short | gawk '{ if ($2 == ${dq}Ethernet${dq}) print $1; }' ";
    +	if (exec($cmd, $output, $retval) === false || $retval !== 0) {
    +		echo "<div class='errorMsgBig'>Unable to get list of network devices</div>";
    +		return;
    +	}
    +
    +	foreach($output as $interface) {
    +		if ($interface === "") continue;
    +		$num_interfaces++;
    +		if ($num_interfaces > 1) {
    +			echo "<hr class='panel-primary'>";
    +		}
    +		process_LAN_data($interface);
    +	}
    +	if ($num_interfaces > 1) echo "<hr class='panel-primary'>";
    +?>
    +		<div class="panel-footer">Information provided by ifconfig</div>
    +	</div><!-- /.panel-primary -->
    +</div><!-- /.col-lg-12 -->
    +<?php
    +}
    +
    +function process_LAN_data($interface)
    +{
     	global $page;
    +	$myStatus = new StatusMessages();
     
     	// Unlike with WLAN where when it's UP it's also RUNNING,
     	// with the LAN, the port can be up but nothing connected, i.e., not "RUNNING".
     
    -	$status = new StatusMessages();
    -
     	$interface_output = get_interface_status("ifconfig $interface");
     
     	// $interface_output is sent and the other variables are returned.
     	parse_ifconfig($interface_output, $strHWAddress, $strIPAddress, $strNetMask, $strRxPackets, $strTxPackets, $strRxBytes, $strTxBytes);
     
    -	// $interface and $interface_output are sent, $status is returned.
    -	$interface_up = handle_interface_POST_and_status($interface, $interface_output, $status);
    +	// $interface and $interface_output are sent, $myStatus is returned.
    +	$interface_up = handle_interface_POST_and_status($interface, $interface_output, $myStatus);
     ?>
     
    -<div class="row">
    -<div class="col-lg-12">
    -	<div class="panel panel-primary">
    -		<div class="panel-heading"><i class="fa fa-network-wired fa-fw"></i> LAN Dashboard</div>
     		<div class="panel-body">
    -			<?php if ($status->isMessage()) echo "<p>" . $status->showMessages() . "</p>"; ?>
    +			<?php if ($myStatus->isMessage()) echo "<p>" . $myStatus->showMessages() . "</p>"; ?>
     			<div class="row">
     				<div class="panel panel-default">
     					<div class="panel-body">
    @@ -45,10 +74,11 @@ function DisplayDashboard_LAN($interface) {
     				<div class="row">
     				<form action="?page=<?php echo $page ?>" method="POST">
     <?php
    +					echo "<input type='submit'";
     					if ( ! $interface_up ) {
    -						echo "<input type='submit' class='btn btn-success' value='Start $interface' name='turn_up' />";
    +						echo " class='btn btn-success' value='Start $interface' name='turn_up' />";
     					} else {
    -						echo "<input type='submit' class='btn btn-warning' value='Stop $interface' name='turn_down' />";
    +						echo " class='btn btn-warning' value='Stop $interface' name='turn_down' />";
     					}
     ?>
     					<input type="button" class="btn btn-primary" value="Refresh" onclick="document.location.reload(true)" />
    @@ -57,10 +87,6 @@ function DisplayDashboard_LAN($interface) {
     			</div>
     
     		</div><!-- /.panel-body -->
    -		<div class="panel-footer">Information provided by ifconfig</div>
    -	</div><!-- /.panel-primary -->
    -</div><!-- /.col-lg-12 -->
    -</div><!-- /.row -->
     <?php 
     }
     ?>
    diff --git a/html/includes/dashboard_WLAN.php b/html/includes/dashboard_WLAN.php
    index f2e27c8b3..04cc808ff 100644
    --- a/html/includes/dashboard_WLAN.php
    +++ b/html/includes/dashboard_WLAN.php
    @@ -1,69 +1,68 @@
     <?php
     
    -function DisplayDashboard_WLAN() {
    -
    -	// Get infomation on each interface and store in the $data array.
    -	// Stop when the interface isn't found.
    -	// This assumes if there are N interfaces there numbers are from 0 to (N-1).
    -	// TODO: That assumption needs to be verified.
    -
    -	$data = Array();
    -	for ($i = 0; ; $i++) {
    -		$interface = "wlan$i";
    -		$interface_output = get_interface_status("ifconfig $interface; iwconfig $interface");
    -		if ($interface_output == "") {
    -			break;
    -		}
    -		$data[$interface] = $interface_output;
    -	}
    +function DisplayDashboard_WLAN()
    +{
    +
     ?>
    -<div class="row">
    -	<div class="col-lg-12">
    -		<div class="panel panel-primary">
    -			<div class="panel-heading"><i class="fa fa-tachometer-alt fa-fw"></i> WLAN Dashboard   </div>
    +<div class="col-lg-12">
    +	<div class="panel panel-primary">
    +		<div class="panel-heading"><i class="fa fa-tachometer-alt fa-fw"></i> WLAN Dashboard</div>
     <?php
    -	$num = 1;
    -	foreach ($data as $int => $v) {
    -		process_WLAN_data($int, $v, $num++);
    +	$num_interfaces = 0;
    +
    +	$dq = '"';		// double quote
    +	$cmd = "hwinfo --network --short | gawk '{ if ($2 == ${dq}WLAN${dq}) print $1; }' ";
    +	if (exec($cmd, $output, $retval) === false || $retval !== 0) {
    +		echo "<div class='errorMsgBig'>Unable to get list of network devices</div>";
    +		return;
     	}
    +
    +	foreach($output as $interface) {
    +		if ($interface === "") continue;
    +		$num_interfaces++;
    +		if ($num_interfaces > 1) {
    +			echo "<hr class='panel-primary'>";
    +		}
    +		process_WLAN_data($interface);
    +	}
    +	if ($num_interfaces > 1) echo "<hr class='panel-primary'>";
     ?>
    -			<div class="panel-footer">Information provided by ifconfig and iwconfig</div>
    -		</div><!-- /.panel-default -->
    -	</div><!-- /.col-lg-12 -->
    -</div><!-- /.row -->
    +	<div class="panel-footer">Information provided by ifconfig and iwconfig</div>
    +	</div><!-- /.panel panel-primary -->
    +</div><!-- /.col-lg-12 -->
     <?php
     }
     
    -function process_WLAN_data($interface, $interface_output, $numCalls)
    +function process_WLAN_data($interface)
     {
     	global $page;
    -	$status = new StatusMessages();
    -	$notSetMsg = "[not set]";
    +	$myStatus = new StatusMessages();
     
    -	if ($numCalls > 1) {
    -		echo "<hr class='rowSeparator' style='height: 5px;'>";
    -	}
    +	$interface_output = get_interface_status("ifconfig $interface; iwconfig $interface");
    +
    +	$notSetMsg = "[not set]";
     
     	// $interface_output is sent and the other variables are returned.
     	parse_ifconfig($interface_output, $strHWAddress, $strIPAddress, $strNetMask, $strRxPackets, $strTxPackets, $strRxBytes, $strTxBytes);
     
     	// parse the iwconfig data:
    -	preg_match( '/ESSID:\"([a-zA-Z0-9\s]+)\"/i',$interface_output,$result );
    -	$strSSID = isset($result[1]) ?  str_replace('"','',$result[1]) : $notSetMsg;
    +	preg_match( '/ESSID:\"([-a-zA-Z0-9\s]+)\"/i', $interface_output, $result );
    +	$strSSID = getVariableOrDefault($result, 1, $notSetMsg);
    +	$strSSID = str_replace('"','', $strSSID);
     
    -	preg_match( '/Access Point: ([0-9a-f:]+)/i',$interface_output,$result );
    -	$strBSSID = isset($result[1]) ?  $result[1] : $notSetMsg;
    +	preg_match( '/Access Point: ([0-9a-f:]+)/i', $interface_output, $result );
    +	$strBSSID = getVariableOrDefault($result, 1, $notSetMsg);
     
    -	preg_match( '/Bit Rate=([0-9\.]+ Mb\/s)/i',$interface_output,$result );
    -	$strBitrate = isset($result[1]) ?  $result[1] : $notSetMsg;
    +	preg_match( '/Bit Rate=([0-9\.]+ Mb\/s)/i', $interface_output, $result );
    +	$strBitrate = getVariableOrDefault($result, 1, $notSetMsg);
     
    -	preg_match( '/Tx-Power=([0-9]+ dBm)/i',$interface_output,$result );
    -	$strTxPower = isset($result[1]) ?  $result[1] : $notSetMsg;
    +	preg_match( '/Tx-Power=([0-9]+ dBm)/i', $interface_output, $result );
    +	$strTxPower = getVariableOrDefault($result, 1, $notSetMsg);
     
     	// for example:   Link Quality=63/70.  Show absolute number (63) and percent (90%)
    -	preg_match( '/Link Quality=([0-9]+)\/([0-9]+)/i',$interface_output,$result );
    -	$strLinkQualityAbsolute = isset($result[1]) ?  $result[1] : $notSetMsg;
    -	$strLinkQualityMax = isset($result[2]) ?  $result[2] : $strLinkQualityAbsolute;
    +	preg_match( '/Link Quality=([0-9]+)\/([0-9]+)/i', $interface_output, $result );
    +	$strLinkQualityAbsolute = getVariableOrDefault($result, 1, $notSetMsg);
    +	$strLinkQualityMax = getVariableOrDefault($result, 2, $strLinkQualityAbsolute);
     	if ($strLinkQualityAbsolute !== $notSetMsg && $strLinkQualityMax !== $notSetMsg) {
     		$strLinkQualityPercent = number_format(($strLinkQualityAbsolute / $strLinkQualityMax) * 100, 0);
     		if ($strLinkQualityPercent >= 75)
    @@ -77,77 +76,80 @@ function process_WLAN_data($interface, $interface_output, $numCalls)
     		$strLinkQuality_status = "info";
     	}
     
    -	preg_match( '/Signal level=(-?[0-9]+ dBm)/i',$interface_output,$result );
    -	$strSignalLevel = isset($result[1]) ?  $result[1] : $notSetMsg;
    +	preg_match( '/Signal level=(-?[0-9]+ dBm)/i', $interface_output, $result );
    +	$strSignalLevel = getVariableOrDefault($result, 1, $notSetMsg);
     
    -	preg_match('/Frequency:(\d+.\d+ GHz)/i',$interface_output,$result);
    -	$strFrequency = isset($result[1]) ?  $result[1] : $notSetMsg;
    +	preg_match('/Frequency:(\d+.\d+ GHz)/i', $interface_output, $result);
    +	$strFrequency = getVariableOrDefault($result, 1, $notSetMsg);
     
    -	// $interface and $interface_output are sent, $status is returned.
    -	$interface_up = handle_interface_POST_and_status($interface, $interface_output, $status);
    +	// $interface and $interface_output are sent, $myStatus is returned.
    +	$interface_up = handle_interface_POST_and_status($interface, $interface_output, $myStatus);
     ?>
    -			<div class="panel-body">
    -				<?php if ($status->isMessage()) echo "<p>" . $status->showMessages() . "</p>"; ?>
    -				<div class="row">
    -					<div class="panel panel-default">
    -						<div class="panel-body">
    -							<h4><?php echo $interface ?> Interface Information</h4>
    -<!--
    -							<div class="info-item">Interface Name</div> <?php echo $interface ?></br>
    --->
    -							<div class="info-item">IP Address</div>     <?php echo $strIPAddress ?></br>
    -							<div class="info-item">Subnet Mask</div>    <?php echo $strNetMask ?></br>
    -							<div class="info-item">Mac Address</div>    <?php echo $strHWAddress ?></br></br>
    -
    -							<h4>Interface Statistics</h4>
    -							<div class="info-item">Received Packets</div>    <?php echo $strRxPackets ?></br>
    -							<div class="info-item">Received Bytes</div>      <?php echo $strRxBytes ?></br></br>
    -							<div class="info-item">Transferred Packets</div> <?php echo $strTxPackets ?></br>
    -							<div class="info-item">Transferred Bytes</div>   <?php echo $strTxBytes ?></br>
    -						</div><!-- /.panel-body -->
    -					</div><!-- /.panel-default -->
    -
    -					<div class="panel panel-default">
    -						<div class="panel-body wireless">
    -							<h4>Wireless Information</h4>
    -							<div class="info-item">Connected To</div>   <?php echo $strSSID ?></br>
    -							<div class="info-item">AP Mac Address</div> <?php echo $strBSSID ?></br>
    -							<div class="info-item">Bitrate</div>        <?php echo $strBitrate ?></br>
    -							<div class="info-item">Signal Level</div>   <?php echo $strSignalLevel ?></br>
    -							<div class="info-item">Transmit Power</div> <?php echo $strTxPower ?></br>
    -							<div class="info-item">Frequency</div>      <?php echo $strFrequency ?></br>
    -							<div class="info-item">Link Quality</div>
    +				<div class="panel-body">
    +					<?php if ($myStatus->isMessage()) echo "<p>" . $myStatus->showMessages() . "</p>"; ?>
    +					<div class="row">
    +						<div class="panel panel-default">
    +							<div class="panel-body">
    +								<h4><?php echo $interface ?> Interface Information</h4>
    +								<div class="info-item">IP Address</div>     <?php echo $strIPAddress ?><br>
    +								<div class="info-item">Subnet Mask</div>    <?php echo $strNetMask ?><br>
    +								<div class="info-item">Mac Address</div>    <?php echo $strHWAddress ?><br><br>
    +
    +								<h4>Interface Statistics</h4>
    +								<div class="info-item">Received Packets</div>    <?php echo $strRxPackets ?><br>
    +								<div class="info-item">Received Bytes</div>      <?php echo $strRxBytes ?><br><br>
    +								<div class="info-item">Transferred Packets</div> <?php echo $strTxPackets ?><br>
    +								<div class="info-item">Transferred Bytes</div>   <?php echo $strTxBytes ?><br>
    +							</div><!-- /.panel-body -->
    +						</div><!-- /.panel panel-default -->
    +
    +						<div class="panel panel-default">
    +							<div class="panel-body wireless">
    +								<h4>Wireless Information</h4>
    +								<div class="info-item">Connected To</div>   <?php echo $strSSID ?><br>
    +								<div class="info-item">AP Mac Address</div> <?php echo $strBSSID ?><br>
    +								<div class="info-item">Bitrate</div>        <?php echo $strBitrate ?><br>
    +								<div class="info-item">Signal Level</div>   <?php echo $strSignalLevel ?><br>
    +								<div class="info-item">Transmit Power</div> <?php echo $strTxPower ?><br>
    +								<div class="info-item">Frequency</div>      <?php echo $strFrequency ?><br>
    +								<div class="info-item">Link Quality</div>
     <?php
    -						if ($strLinkQualityPercent == $notSetMsg) echo "$notSetMsg </br>";
    +						if ($strLinkQualityPercent == $notSetMsg) echo "$notSetMsg <br>";
     						else {
     ?>
    -							<div class="progress">
    -								<div class="progress-bar progress-bar-<?php echo $strLinkQuality_status ?>"
    -								role="progressbar"
    -								aria-valuenow="<?php echo $strLinkQualityPercent ?>" aria-valuemin="0" aria-valuemax="100"
    -								style="width: <?php echo $strLinkQualityPercent ?>%;"><?php echo "$strLinkQualityPercent% &nbsp; &nbsp; ($strLinkQualityAbsolute / $strLinkQualityMax)" ?>
    +								<div class="progress">
    +									<div class="progress-bar progress-bar-<?php echo $strLinkQuality_status ?>"
    +										role="progressbar"
    +										aria-valuenow="<?php echo $strLinkQualityPercent ?>"
    +										aria-valuemin="0" aria-valuemax="100"
    +										style="width: <?php echo $strLinkQualityPercent ?>%;">
    +										<?php echo "$strLinkQualityPercent% &nbsp; &nbsp; ";
    +								  			echo "($strLinkQualityAbsolute / $strLinkQualityMax)\n";
    +										?>
    +									</div>
     								</div>
    -							</div>
     <?php						} ?>
    -						</div><!-- /.panel-body -->
    -					</div><!-- /.panel-default -->
    -				</div><!-- /.row -->
    +							</div><!-- /.panel-body wireless -->
    +						</div><!-- /.panel panel-default -->
    +					</div><!-- /.row -->
     
    -				<div class="col-lg-12">
    -					<div class="row">
    +					<div class="col-lg-12">
    +						<div class="row">
     						<form action="?page=<?php echo $page ?>" method="POST">
    -							<?php if ( !$interface_up ) {
    -								echo "<input type='submit' class='btn btn-success' value='Start $interface' name='turn_up' />";
    +<?php
    +							echo "<input type='submit'";
    +							if ( !$interface_up ) {
    +								echo " class='btn btn-success' value='Start $interface' name='turn_up' />";
     							} else {
    -								echo "<input type='submit' class='btn btn-warning' value='Stop $interface' name='turn_down' />";
    +								echo " class='btn btn-warning' value='Stop $interface' name='turn_down' />";
     							}
    -							?>
    +							echo "\n";
    +?>
     							<input type="button" class="btn btn-primary" value="Refresh" onclick="document.location.reload(true)" />
     						</form>
    +						</div>
     					</div>
    -				</div>
    -			</div><!-- /.panel-body -->
    -
    +				</div><!-- /.panel-body -->
     <?php
     }
     ?>
    diff --git a/html/includes/datautil.php b/html/includes/datautil.php
    index ac1dfe2d4..7d89d3079 100644
    --- a/html/includes/datautil.php
    +++ b/html/includes/datautil.php
    @@ -3,9 +3,6 @@
     include_once('functions.php');
     initialize_variables();		// sets some variables
     
    -define('RASPI_ADMIN_DETAILS', RASPI_CONFIG . '/raspap.auth');
    -
    -include_once('raspap.php');
     include_once('authenticate.php');
     
     class DATAUTIL
    @@ -92,17 +89,13 @@ private function haveDatabase() {
         }
     
         public function getStartup() {
    -
    +		global $settings_array;		// Set in initialize_variables()
             $haveDatabase = $this->haveDatabase();
     
             if ($haveDatabase) {
    -            $cam_type = getCameraType();
    -            $settings_file = getSettingsFile($cam_type);
    -            $camera_settings_str = file_get_contents($settings_file, true);
    -            $camera_settings_array = json_decode($camera_settings_str, true);
    -            $angle = $camera_settings_array['angle'];
    -            $lat = $camera_settings_array['latitude'];
    -            $lon = $camera_settings_array['longitude'];
    +            $angle = $settings_array['angle'];
    +            $lat = $settings_array['latitude'];
    +            $lon = $settings_array['longitude'];
     
                 $tod = 'Unknown';
                 exec("sunwait poll exit set angle $angle $lat $lon", $return, $retval);
    @@ -134,7 +127,7 @@ public function getStartup() {
                     $days[ $row['folder']] = [
                         'text' => ''
                     ];
    -            }   
    +            }
     
                 $result = [
                     'tod' => $tod,
    @@ -148,7 +141,7 @@ public function getStartup() {
                 $this->send404();
             }
         }
    -    
    +
         public function getLiveData() {
             $db = new SQLite3(ALLSKY_HOME . '/' . $this->database);
     
    @@ -167,7 +160,7 @@ public function getLiveData() {
                 'meteors' => [],
                 'exposure' => [],
                 'gain' => [],
    -            'skystate' => []                                  
    +            'skystate' => []
             ];
     
             $lastHour = -1;
    @@ -193,7 +186,7 @@ public function getLiveData() {
                             'hour' => $lastHour,
                             'count' => $count,
                             'total' => $total,
    -                        'percentage' => $percentage                        
    +                        'percentage' => $percentage
                         ];
                         $lastHour = $hour;
                         $count = 0;
    @@ -222,7 +215,7 @@ public function getLiveData() {
                 array_push($hours, $state['hour']);
                 array_push($skyState, $state['count']);
                 array_push($percentage, $state['percentage']);
    -            array_push($total, $state['total']);            
    +            array_push($total, $state['total']);
             }
             $data['hours'] = $hours;
             $data['clearimages'] = $skyState;
    @@ -244,7 +237,7 @@ public function getData() {
                 'meteors' => [],
                 'exposure' => [],
                 'gain' => [],
    -            'skystate' => []                                  
    +            'skystate' => []
             ];
     
             $lastHour = -1;
    @@ -270,7 +263,7 @@ public function getData() {
                             'hour' => $lastHour,
                             'count' => $count,
                             'total' => $total,
    -                        'percentage' => $percentage                        
    +                        'percentage' => $percentage
                         ];
                         $lastHour = $hour;
                         $count = 0;
    @@ -298,7 +291,7 @@ public function getData() {
                 array_push($hours, $state['hour']);
                 array_push($skyState, $state['count']);
                 array_push($percentage, $state['percentage']);
    -            array_push($total, $state['total']);            
    +            array_push($total, $state['total']);
             }
             $data['hours'] = $hours;
             $data['clearimages'] = $skyState;
    diff --git a/html/includes/days.php b/html/includes/days.php
    index d3ec3f2bc..b14a13c59 100644
    --- a/html/includes/days.php
    +++ b/html/includes/days.php
    @@ -122,9 +122,8 @@ function ListDays(){
     
     	echo "\t\t\t<td style='padding: 5px'>
     				<button type='submit' data-toggle='confirmation'
    -					class='btn btn-danger' style='text-align: center, color:white'
    -					name='delete_directory' value='$day'>
    -					<i class='fa fa-trash text-danger' style='color:white'></i> <span class='hidden-xs'>Delete</span>
    +					class='btn btn-delete' name='delete_directory' value='$day'>
    +					<i class='fa fa-trash'></i> <span class='hidden-xs'>Delete</span>
     				</button>
     			</td>
     		</tr>";
    diff --git a/html/includes/dhcp.php b/html/includes/dhcp.php
    index 580bf219d..7dde9ed5f 100644
    --- a/html/includes/dhcp.php
    +++ b/html/includes/dhcp.php
    @@ -1,220 +1,295 @@
     <?php
     
    -include_once( 'includes/status_messages.php' );
    -
     /**
     *
     * Manage DHCP configuration
     *
     */
     function DisplayDHCPConfig() {
    -  global $page;
    -
    -  $status = new StatusMessages();
    -  if( isset( $_POST['savedhcpdsettings'] ) ) {
    -    if (CSRFValidate()) {
    -      $config = 'interface='.$_POST['interface'].PHP_EOL
    -        .'dhcp-range='.$_POST['RangeStart'].','.$_POST['RangeEnd'].',255.255.255.0,'.$_POST['RangeLeaseTime'].''.$_POST['RangeLeaseTimeUnits'];
    -      exec( 'echo "'.$config.'" > /tmp/dhcpddata',$temp );
    -      system( 'sudo cp /tmp/dhcpddata '. RASPI_DNSMASQ_CONFIG, $return );
    -
    -      if( $return == 0 ) {
    -        $status->addMessage('Dnsmasq configuration updated successfully', 'success');
    -      } else {
    -        $status->addMessage('Dnsmasq configuration failed to be updated', 'danger');
    -      }
    -    } else {
    -      error_log('CSRF violation');
    -    }
    -  }
    -
    -  exec( 'pidof dnsmasq | wc -l',$dnsmasq );
    -  $dnsmasq_state = ($dnsmasq[0] > 0);
    -
    -  if( isset( $_POST['startdhcpd'] ) ) {
    -    if (CSRFValidate()) {
    -      if ($dnsmasq_state) {
    -        $status->addMessage('dnsmasq already running', 'info');
    -      } else {
    -        exec('sudo /etc/init.d/dnsmasq start', $dnsmasq, $return);
    -        if ($return == 0) {
    -          $status->addMessage('Successfully started dnsmasq', 'success');
    -          $dnsmasq_state = true;
    -        } else {
    -          $status->addMessage('Failed to start dnsmasq', 'danger');
    -        }
    -      }
    -    } else {
    -      error_log('CSRF violation');
    -    }
    -  } elseif( isset($_POST['stopdhcpd'] ) ) {
    -    if (CSRFValidate()) {
    -      if ($dnsmasq_state) {
    -        exec('sudo /etc/init.d/dnsmasq stop', $dnsmasq, $return);
    -        if ($return == 0) {
    -          $status->addMessage('Successfully stopped dnsmasq', 'success');
    -          $dnsmasq_state = false;
    -        } else {
    -          $status->addMessage('Failed to stop dnsmasq', 'danger');
    -        }
    -      } else {
    -        $status->addMessage('dnsmasq already stopped', 'info');
    -      }
    -    } else {
    -      error_log('CSRF violation');
    -    }
    -  } else {
    -    if( $dnsmasq_state ) {
    -      $status->addMessage('Dnsmasq is running', 'success');
    -    } else {
    -      $status->addMessage('Dnsmasq is not running', 'warning');
    -    }
    -  }
    -
    -  exec( 'cat '. RASPI_DNSMASQ_CONFIG, $return );
    -  $conf = ParseConfig($return);
    -  $arrRange = explode( ",", $conf['dhcp-range'] );
    -  $RangeStart = $arrRange[0];
    -  $RangeEnd = $arrRange[1];
    -  $RangeMask = $arrRange[2];
    -  preg_match( '/([0-9]*)([a-z])/i', $arrRange[3], $arrRangeLeaseTime );
    -
    -  switch( $arrRangeLeaseTime[2] ) {
    -    case "h":
    -      $hselected = " selected";
    -    break;
    -    case "m":
    -      $mselected = " selected";
    -    break;
    -    case "d":
    -      $dselected = " selected";
    -    break;
    -  }
    -
    -  ?>
    -  <div class="row">
    -  <div class="col-lg-12">
    -      <div class="panel panel-primary">
    -      <div class="panel-heading"><i class="fa fa-exchange fa-fw"></i> Configure DHCP
    -            </div>
    -        <!-- /.panel-heading -->
    -        <div class="panel-body">
    -		<?php if ($status->isMessage()) echo "<p>" . $status->showMessages() . "</p>"; ?>
    -        <!-- Nav tabs -->
    -            <ul class="nav nav-tabs">
    -                <li class="active"><a href="#server-settings" data-toggle="tab">Server settings</a>
    -                </li>
    -                <li><a href="#client-list" data-toggle="tab">Client list</a>
    -                </li>
    -            </ul>
    -        <!-- Tab panes -->
    -        <div class="tab-content">
    -    <div class="tab-pane fade in active" id="server-settings">
    -    <h4>DHCP server settings</h4>
    -    <form method="POST" action="?page=<?php echo $page ?>">
    -    <?php CSRFToken() ?>
    -    <div class="row">
    -      <div class="form-group col-md-4">
    -        <label for="code">Interface</label>
    -        <select class="form-control" name="interface">
    -        <?php 
    -        exec("ip -o link show | awk -F': ' '{print $2}'", $interfaces);
    -
    -        foreach( $interfaces as $int ) {
    -          $select = '';
    -          if( $int == $conf['interface'] ) {
    -            $select = " selected";
    -          }
    -            echo '<option value="'.$int.'"'.$select.'>'.$int.'</option>';
    -          }
    -        ?>
    -        </select>
    -      </div>
    -    </div>
    -    <div class="row">
    -      <div class="form-group col-md-4">
    -        <label for="code">Starting IP Address</label>
    -        <input type="text" class="form-control"name="RangeStart" value="<?php echo $RangeStart; ?>" />
    -      </div>
    -    </div>
    -
    -    <div class="row">
    -      <div class="form-group col-md-4">
    -        <label for="code">Ending IP Address</label>
    -        <input type="text" class="form-control" name="RangeEnd" value="<?php echo $RangeEnd; ?>" />
    -      </div>
    -    </div>
    -
    -    <div class="row">
    -      <div class="form-group col-xs-2 col-sm-2">
    -        <label for="code">Lease Time</label>
    -        <input type="text" class="form-control" name="RangeLeaseTime" value="<?php echo $arrRangeLeaseTime[1]; ?>" />
    -      </div>
    -      <div class="col-xs-2 col-sm-2">
    -        <label for="code">Interval</label>
    -        <select name="RangeLeaseTimeUnits" class="form-control" ><option value="m" <?php echo $mselected; ?>>Minute(s)</option><option value="h" <?php echo $hselected; ?>>Hour(s)</option><option value="d" <?php echo $dselected; ?>>Day(s)</option><option value="infinite">Infinite</option></select> 
    -      </div>
    -    </div>
    -
    -    <input type="submit" class="btn btn-primary" value="Save settings" name="savedhcpdsettings" />
    -    <?php
    -
    -    if ( $dnsmasq_state ) {
    -      echo '<input type="submit" class="btn btn-warning" value="Stop dnsmasq" name="stopdhcpd" />';
    -    } else {
    -      echo'<input type="submit" class="btn btn-success" value="Start dnsmasq" name="startdhcpd" />';
    -    }
    -    ?>
    -    </form>
    -    </div><!-- /.tab-pane -->
    -
    -    <div class="tab-pane fade in" id="client-list">
    -    <h4>Client list</h4>
    -    <div class="col-lg-12">
    -      <div class="panel panel-default">
    -      <div class="panel-heading">
    -        Active DHCP leases
    -      </div>
    -      <!-- /.panel-heading -->
    -      <div class="panel-body">
    -        <div class="table-responsive">
    -          <table class="table table-hover">
    -            <thead>
    -              <tr>
    -                <th>Expire time</th>
    -                <th>MAC Address</th>
    -                <th>IP Address</th>
    -                <th>Host name</th>
    -                <th>Client ID</th>
    -              </tr>
    -            </thead>
    -            <tbody>
    -              <tr>
    -                <?php
    -                exec( 'cat ' . RASPI_DNSMASQ_LEASES, $leases );
    -                foreach( $leases as $lease ) {
    -                  $lease_items = explode(' ', $lease);
    -                  foreach( $lease_items as $lease_item ) {
    -                    echo '<td>' . $lease_item . '</td>';
    -                  }
    -                  echo '</tr>';
    -                };
    -                ?>
    -              </tr>
    -            </tbody>
    -          </table>
    -        </div><!-- /.table-responsive -->
    -      </div><!-- /.panel-body -->
    -      </div><!-- /.panel -->
    -    </div><!-- /.col-lg-6 -->
    -    </div><!-- /.tab-pane -->
    -    </div><!-- /.tab-content -->
    -    </div><!-- ./ Panel body -->
    -    <div class="panel-footer"> Information provided by Dnsmasq</div>
    -        </div><!-- /.panel-primary -->
    -    </div><!-- /.col-lg-12 -->
    -  </div><!-- /.row -->
    +	global $page;
    +	$myStatus = new StatusMessages();
    +
    +	$interface = null;
    +	$RangeStart = "";
    +	$RangeEnd = "";
    +	$RangeLeaseTime = "";
    +	$hselected = ""; $mselected = ""; $dselected = "";
    +	$infinite = "Infinite";
    +
    +	if( isset( $_POST['savedhcpdsettings'] ) ) {
    +		if (CSRFValidate()) {
    +			$ok = true;
    +			$interface = getVariableOrDefault($_POST, 'interface', "");
    +			if ($interface === "") {
    +				$myStatus->addMessage('<strong>Interface</strong> not specified', 'danger');
    +				$ok = false;
    +			}
    +			$RangeStart = getVariableOrDefault($_POST, 'RangeStart', "");
    +			if ($RangeStart === "") {
    +				$myStatus->addMessage('<strong>Starting IP Address</strong> not specified', 'danger');
    +				$ok = false;
    +			}
    +			$RangeEnd = getVariableOrDefault($_POST, 'RangeEnd', "");
    +			if ($RangeEnd === "") {
    +				$myStatus->addMessage('<strong>Ending IP Address</strong> not specified', 'danger');
    +				$ok = false;
    +			}
    +			$RangeLeaseTime = getVariableOrDefault($_POST, 'RangeLeaseTime', "");
    +			if ($RangeLeaseTime !== "") {
    +				// $RangeLeaseTime is optional, but if given, the units must also be given.
    +				$RangeLeaseTimeUnits = getVariableOrDefault($_POST, 'RangeLeaseTimeUnits', "");
    +				if ($RangeLeaseTimeUnits === "") {
    +					$myStatus->addMessage('<strong>Interval</strong> not specified', 'danger');
    +					$ok = false;
    +				} else if ($RangeLeaseTimeUnits === $infinite) {
    +					$msg = "Can not specify a <strong>Lease Time</strong> with an ";
    +					$msg .= "<strong>Interval</strong> of <strong>$infinite</strong>";
    +					$myStatus->addMessage($msg, 'danger');
    +					$ok = false;
    +				}
    +			}
    +
    +			if ($ok) {
    +				$config = "interface=$interface" . PHP_EOL;
    +				$config .= "dhcp-range=$RangeStart,$RangeEnd,255.255.255.0";
    +				if ($RangeLeaseTime !== "")
    +					$config .= ",$RangeLeaseTime$RangeLeaseTimeUnits";
    +				exec( "echo '$config' > /tmp/dhcpddata",$temp );
    +				system( 'sudo cp /tmp/dhcpddata '. RASPI_DNSMASQ_CONFIG, $return );
    +
    +				if( $return == 0 ) {
    +					$myStatus->addMessage('dnsmasq configuration updated successfully', 'success');
    +				} else {
    +					$myStatus->addMessage('dnsmasq configuration failed to be updated', 'danger');
    +				}
    +			} else {
    +				$myStatus->addMessage('No changes made', 'danger');
    +			}
    +		} else {
    +			error_log('CSRF violation');
    +		}
    +	}
    +
    +	exec( 'pidof dnsmasq > /dev/null', $ignored, $return );
    +	$dnsmasq_state = ($return == 0);
    +
    +	if( isset( $_POST['startdhcpd'] ) ) {
    +		if (CSRFValidate()) {
    +			if ($dnsmasq_state) {
    +				$myStatus->addMessage('dnsmasq already running', 'info');
    +			} else {
    +				exec('sudo /etc/init.d/dnsmasq start 2>&1', $dnsmasq, $return);
    +				if ($return == 0) {
    +					$myStatus->addMessage('Successfully started dnsmasq', 'success');
    +					$dnsmasq_state = true;
    +				} else {
    +					$myStatus->addMessage('Failed to start dnsmasq: ' . implode('<br>', $dnsmasq), 'danger');
    +				}
    +			}
    +		} else {
    +			error_log('CSRF violation');
    +		}
    +
    +	} elseif( isset($_POST['stopdhcpd'] ) ) {
    +		if (CSRFValidate()) {
    +			if ($dnsmasq_state) {
    +				exec('sudo /etc/init.d/dnsmasq stop 2>&1', $dnsmasq, $return);
    +				if ($return == 0) {
    +					$myStatus->addMessage('Successfully stopped dnsmasq', 'success');
    +					$dnsmasq_state = false;
    +				} else {
    +					$myStatus->addMessage('Failed to stop dnsmasq: ' . implode('<br>', $dnsmasq), 'danger');
    +				}
    +			} else {
    +				$myStatus->addMessage('dnsmasq already stopped', 'info');
    +			}
    +		} else {
    +			error_log('CSRF violation');
    +		}
    +
    +	} else if( $dnsmasq_state ) {
    +		$myStatus->addMessage('dnsmasq is running', 'success');
    +	} else {
    +		$myStatus->addMessage('dnsmasq is not running', 'warning');
    +	}
    +
    +	exec( 'cat ' . RASPI_DNSMASQ_CONFIG, $return );
    +	if ($return !== null) {
    +		if (count($return) == 0) {
    +			$return = null;
    +			$myStatus->addMessage(RASPI_DNSMASQ_CONFIG . ' appears empty', 'warning');
    +		}
    +	}
    +
    +	if ($return !== null) {
    +		$conf = ParseConfig($return);
    +		$interface = getVariableOrDefault($conf, 'interface', null);
    +		$range = getVariableOrDefault($conf, 'dhcp-range', null);
    +		if ($interface === null) {
    +			$return = null;
    +			$myStatus->addMessage(RASPI_DNSMASQ_CONFIG . ' has no interface', 'warning');
    +		}
    +		if ($range === null) {
    +			$return = null;
    +			$myStatus->addMessage(RASPI_DNSMASQ_CONFIG . ' has no dhcp-range', 'warning');
    +		}
    +	}
    +
    +	if ($return !== null) {
    +		// $range:	start_ip, end_ip, mask [, lease]
    +		// index:	0				 1			 2				3
    +		// count:	1				 2			 3				4
    +		$arrRange = explode( ",", $range );
    +		if (count($arrRange) < 3) {
    +			$myStatus->addMessage("dhcp-range in '" . RASPI_DNSMASQ_CONFIG . " missing fields: $range", "danger");
    +		} else {
    +			$RangeStart = $arrRange[0];
    +			$RangeEnd = $arrRange[1];
    +			$RangeMask = $arrRange[2];
    +			if (count($arrRange) == 4) {
    +				preg_match( '/([0-9]*)([a-z])/i', $arrRange[3], $arrRangeLeaseTime );
    +				$RangeLeaseTime = $arrRangeLeaseTime[1];
    +				switch( $arrRangeLeaseTime[2] ) {
    +				case "h":
    +					$hselected = " selected";
    +					break;
    +				case "m":
    +					$mselected = " selected";
    +					break;
    +				case "d":
    +					$dselected = " selected";
    +					break;
    +				}
    +			}
    +		}
    +	}
    +	$interval = "$mselected$hselected$dselected";
    +?>
    +
    +<div class="row"> <div class="col-lg-12"> <div class="panel panel-primary">
    +	<div class="panel-heading"><i class="fa fa-exchange fa-fw"></i> Configure DHCP</div>
    +	<div class="panel-body">
    +		<?php if ($myStatus->isMessage()) echo "<p>" . $myStatus->showMessages() . "</p>"; ?>
    +
    +		<!-- Nav tabs -->
    +			<ul class="nav nav-tabs">
    +				<li class="active"><a href="#server-settings" data-toggle="tab">DHCP server settings</a></li>
    +				<li><a href="#client-list" data-toggle="tab">Client list</a></li>
    +			</ul>
    +
    +		<!-- Tab panes -->
    +		<div class="tab-content">
    +
    +			<div class="tab-pane fade in active" id="server-settings">
    +				<form method="POST" action="?page=<?php echo $page ?>">
    +				<?php CSRFToken() ?>
    +				<div class="row">
    +					<div class="form-group col-md-4">
    +						<label for="code">Interface</label>
    +						<select class="form-control" name="interface">
    +							<?php 
    +							exec("ip -o link show | awk -F': ' '{print $2}'", $interfaces);
    +							$found = false;
    +							foreach( $interfaces as $int ) {
    +								if( $int == $interface ) {
    +									$select = " selected";
    +									$found = true;
    +								} else {
    +									$select = '';
    +								}
    +								echo "<option value='$int' $select>$int</option>";
    +							}
    +							if (! $found)
    +								echo "<option value='' selected>[PICK ONE]</option>";
    +							?>
    +						</select>
    +					</div><!-- ./ form-group col-md-4 -->
    +				</div><!-- ./ row -->
    +
    +				<div class="row">
    +					<div class="form-group col-md-4">
    +						<label for="code">Starting IP Address</label>
    +						<input type="text" class="form-control"name="RangeStart" value="<?php echo $RangeStart; ?>" />
    +					</div>
    +				</div><!-- ./ row -->
    +
    +				<div class="row">
    +					<div class="form-group col-md-4">
    +						<label for="code">Ending IP Address</label>
    +						<input type="text" class="form-control" name="RangeEnd" value="<?php echo $RangeEnd; ?>" />
    +					</div>
    +				</div><!-- ./ row -->
    +
    +				<div class="row">
    +					<div class="form-group col-xs-2 col-sm-2">
    +						<label for="code">Lease Time &nbsp; (optional)</label>
    +						<input type="text" class="form-control" name="RangeLeaseTime" value="<?php echo $RangeLeaseTime; ?>" />
    +					</div>
    +					<div class="col-xs-2 col-sm-2">
    +						<label for="code">Interval</label>
    +						<select name="RangeLeaseTimeUnits" class="form-control" >
    +							<option value="m" <?php echo $mselected; ?>>Minute(s)</option>
    +							<option value="h" <?php echo $hselected; ?>>Hour(s)</option>
    +							<option value="d" <?php echo $dselected; ?>>Day(s)</option>
    +							<?php
    +							echo "<option value='$infinite'";
    +							if ($interval === "") echo " selected";
    +							echo ">$infinite</option>";
    +							?>
    +						</select> 
    +					</div>
    +				</div><!-- ./ row -->
    +
    +				<input type="submit" class="btn btn-primary" value="Save settings" name="savedhcpdsettings" />
    +				<?php
    +				if ( $dnsmasq_state )
    +					echo '<input type="submit" class="btn btn-warning" value="Stop dnsmasq"	name="stopdhcpd" />';
    +				else
    +					echo '<input type="submit" class="btn btn-success" value="Start dnsmasq" name="startdhcpd" />';
    +				?>
    +				</form>
    +			</div><!-- /.tab-pane -->
    +
    +			<div class="tab-pane fade in" id="client-list">
    +				<div class="col-lg-12">
    +					<div class="panel panel-default">
    +						<div class="panel-heading">Active DHCP leases</div>
    +						<div class="panel-body">
    +							<div class="table-responsive">
    +								<table class="table table-hover">
    +									<thead>
    +										<tr>
    +											<th>Expire time</th>
    +											<th>MAC Address</th>
    +											<th>IP Address</th>
    +											<th>Host name</th>
    +											<th>Client ID</th>
    +										</tr>
    +									</thead>
    +									<tbody>
    +										<tr>
    +											<?php
    +											exec( 'cat ' . RASPI_DNSMASQ_LEASES, $leases );
    +											foreach( $leases as $lease ) {
    +												$lease_items = explode(' ', $lease);
    +												foreach( $lease_items as $lease_item ) {
    +													echo '<td>' . $lease_item . '</td>';
    +												}
    +												echo '</tr>';
    +											};
    +											?>
    +										</tr>
    +									</tbody>
    +								</table>
    +							</div><!-- /.table-responsive -->
    +						</div><!-- /.panel-body -->
    +					</div><!-- /.panel -->
    +				</div><!-- /.col-lg-12 -->
    +			</div><!-- /.tab-pane -->
    +		</div><!-- /.tab-content -->
    +	</div><!-- ./ Panel body -->
    +	<div class="panel-footer"> Information provided by dnsmasq</div>
    +</div><!-- /.panel-primary --> </div><!-- /.col-lg-12 --> </div><!-- /.row -->
     <?php
     }
    -
     ?>
    diff --git a/html/includes/editor.php b/html/includes/editor.php
    index ec78d90af..9c0d54107 100644
    --- a/html/includes/editor.php
    +++ b/html/includes/editor.php
    @@ -2,17 +2,79 @@
     
     function DisplayEditor()
     {
    -	$status = new StatusMessages();
    -?>
    +	global $useLocalWebsite, $useRemoteWebsite;
    +	global $hasLocalWebsite, $hasRemoteWebsite;
    +	$myStatus = new StatusMessages();
    +
    +	$fullN = null;			// this is the file that's displayed by default
    +	$localN = basename(getLocalWebsiteConfigFile());
    +	$fullLocalN = "website/$localN";
    +	$remoteN = basename(getRemoteWebsiteConfigFile());
    +	$fullRemoteN = "config/$remoteN";
    +
    +	// See what files there are to edit.
    +	$numFiles = 0;
    +
    +	if ($hasLocalWebsite) {
    +		$fullN = $fullLocalN;
    +		$numFiles++;
    +		if (! $useLocalWebsite) {
    +			$msg = "<span class='WebUISetting'>Use Local Website</span> is not enabled.";
    +			$msg .= "<br>Your changes won't take effect until you enable that setting.</span>";
    +			$myStatus->addMessage($msg, 'danger');
    +		}
    +	} else {
    +		$localN = null;
    +	}
     
    -	<script type="text/javascript">
    +	if ($hasRemoteWebsite) {
    +		if ($fullN === null) $fullN = $fullRemoteN;
    +		$numFiles++;
    +		if (! $useRemoteWebsite) {
    +			$msg = "<span class='WebUISetting'>Use Remote Website</span> is not enabled.";
    +			$msg .= "<br>Your changes won't take effect until you enable that setting.</span>";
    +			$myStatus->addMessage($msg, 'danger');
    +		}
    +	} else {
    +		$remoteN = null;
    +	}
     
    +if (true) {
    +	$envN = null;	// Don't allow users to edit - they should use the Allsky Settings page.
    +} else {
    +	$envN = basename(ALLSKY_ENV);
    +	$fullenvN = "current/$envN";
    +	if (file_exists(ALLSKY_ENV)) {
    +		if ($fullN === null) $fullN = $fullenvN;
    +		$numFiles++;
    +	} else {
    +		$envN = null;
    +	}
    +}
    +
    +	if ($numFiles > 0) {
    +		if ($fullN === null) {
    +			if ($hasLocalWebsite)
    +				$fullN = $fullLocalN;
    +			else
    +				$fullN = $fullRemoteN;
    +		}
    +?>
    +		<script type="text/javascript">
     		$(document).ready(function () {
    +
     			var editor = null;
    -			$.get("config/config.sh?_ts=" + new Date().getTime(), function (data) {
    +			$.get("<?php echo $fullN; ?>", function (data) {
    +
    +				// .json files return "data" as json array, and we need a regular string.
    +				// Get around this by stringify'ing "data".
    +				if (typeof data != 'string') {
    +					data = JSON.stringify(data, null, "\t");
    +				}
    +
     				editor = CodeMirror(document.querySelector("#editorContainer"), {
     					value: data,
    -					mode: "shell",
    +					mode: "json",
     					theme: "monokai"
     				});
     			});
    @@ -40,11 +102,14 @@ function DisplayEditor()
     							// then a tab, then a message.
     							var returnMsg = "";
     							var ok = true;
    +							var c = "success";		// CSS class
     							if (data == "") {
     								returnMsg = "No response from save_file.php";
    +								c = "danger";
     								ok = false;
     							} else {
     								returnArray = data.split("\n");
    +
     								// Check every line in the output.
     								// output any lines not beginnning with "S " or "E ",
     								// they are probably debug lines.
    @@ -52,26 +117,28 @@ function DisplayEditor()
     									var line = returnArray[i];
     									returnStatus = line.substr(0,2);
     									if (returnStatus === "S\t") {
    -										ok = true;
    +										returnMsg += line.substr(2);
    +									} else if (returnStatus === "W\t") {
    +										c = "warning";
     										returnMsg += line.substr(2);
     									} else if (returnStatus === "E\t") {
     										ok = false;
    +										c = "danger";
     										returnMsg += line.substr(2);
     									} else {
    -										// Assume it's a debug statement.
    +										// Assume it's a debug statement.  Display whole line.
    +										c = "info";
     										console.log(line);
     									}
     								}
     							}
    -							var c = ok ? "success" : "danger";
     							var messages = document.getElementById("editor-messages");
     							if (messages === null) {
     								ok = false;
    +								c = "danger";
     								returnMsg = "No response from save_file.php";
     							}
    -							var m = '<div class="alert alert-' + c + '">' + returnMsg;
    -							m += '<button type="button" class="close" data-dismiss="alert" aria-hidden="true">x</button>';
    -							m += '</div>';
    +							var m = '<div class="alert alert-' + c + '">' + returnMsg + '</div>';
     							messages.innerHTML += m;
     						},
     						error: function(XMLHttpRequest, textStatus, errorThrown) {
    @@ -79,9 +146,6 @@ function DisplayEditor()
     						}
     					});
     				}
    -				else{
    -					//alert("File not saved!");
    -				}
     			});
     
     			$("#script_path").change(function(e) {
    @@ -97,7 +161,8 @@ function DisplayEditor()
     				} else {
     					editor.setOption("mode", "shell");
     				}
    -				// It would be easy to support other files types.  Would need "type.js" file to do the formatting.
    +				// It would be easy to support other files types.
    +				// Would need "type.js" file to do the formatting.
     				$.get(fileName + "?_ts=" + new Date().getTime(), function (data) {
     					editor.getDoc().setValue(data);
     				}).fail(function(x) {
    @@ -110,57 +175,56 @@ function DisplayEditor()
     			});
     		});
     
    -	</script>
    +		</script>
    +<?php } ?>
     
     	<div class="row">
     		<div class="col-lg-12">
     			<div class="panel panel-primary">
     				<div class="panel-heading"><i class="fa fa-code fa-fw"></i> Editor</div>
    -				<!-- /.panel-heading -->
     				<div class="panel-body">
    -					<p id="editor-messages"><?php $status->showMessages(); ?></p>
    +					<p id="editor-messages"><?php $myStatus->showMessages(); ?></p>
     					<div id="editorContainer"></div>
    -					<div style="margin-top: 15px;">
    -				 <?php
    -						$scripts = null;
    -						if (file_exists(ALLSKY_SCRIPTS . "/endOfNight_additionalSteps.sh")) {
    -							$scripts[0] = "endOfNight_additionalSteps.sh";
    -						}
    +					<div class="editorBottomSection">
    +				<?php
    +					if ($numFiles === 0) {
    +						echo "<div class='errorMsgBig'>No files to edit</div>";
    +					} else {
     				?>
    -						<select class="form-control" id="script_path" title="Pick a file"
    -							style="display: inline-block; width: auto; margin-right: 15px; margin-bottom: 5px"
    -						>
    -							<option value="config/config.sh">config.sh</option>
    -							<option value="config/ftp-settings.sh">ftp-settings.sh</option>
    -
    +						<select class="form-control editorForm" id="script_path" title="Pick a file">
     				<?php
    -							if ($scripts != null) {
    -								foreach ($scripts as $script) {
    -									echo "<option value='current/" . basename(ALLSKY_SCRIPTS) . "/$script'>$script</option>";
    -								}
    -							}
    -							if (file_exists(ALLSKY_WEBSITE_LOCAL_CONFIG)) {
    +							if ($localN !== null) {
     								// The website is installed on this Pi.
     								// The physical path is ALLSKY_WEBSITE; virtual path is "website".
    -								$N = ALLSKY_WEBSITE_LOCAL_CONFIG_NAME;
    -								echo "<option value='website/$N'>$N (local Allsky Website)</option>";
    +								echo "<option value='$fullLocalN'>";
    +								echo "$localN (local Allsky Website)";
    +								echo "</option>";
     							}
     
    -							if (file_exists(ALLSKY_WEBSITE_REMOTE_CONFIG)) {
    -								// The website is remote, but a copy of the config file is on the Pi.
    -								$N = ALLSKY_WEBSITE_REMOTE_CONFIG_NAME;
    -								echo "<option value='{REMOTE}config/$N'>$N (remote Allsky Website)</option>";
    +							if ($remoteN !== null) {
    +								// A copy of the remote website's config file is on the Pi.
    +								echo "<option value='{REMOTE}$fullRemoteN'>";
    +								echo "$remoteN (remote Allsky Website)";
    +								echo "</option>";
     							}
    -			   ?>
    +
    +							if ($envN !== null) {
    +								echo "<option value='$fullenvN'>";
    +								echo "$envN";
    +								echo "</option>";
    +							}
    +				?>
     						</select>
    -						<button type="submit" class="btn btn-primary" style="margin-bottom:5px" id="save_file"/>
    +						<button type="submit" class="btn btn-primary editorSaveChanges" id="save_file"/>
     							<i class="fa fa-save"></i> Save Changes</button>
    +				<?php
    +					}
    +				?>
     					</div>
     				</div>
     			</div>
     		</div>
     	</div>
    -
     <?php
     }
     ?>
    diff --git a/html/includes/functions.php b/html/includes/functions.php
    index eb3c0fb03..e380b18ec 100644
    --- a/html/includes/functions.php
    +++ b/html/includes/functions.php
    @@ -7,47 +7,59 @@
      *
     */
     
    -// Sets all the define() variables.
    +// This file sets all the define() variables.
     $defs = 'allskyDefines.inc';
     if ((include $defs) == false) {
    -	echo "<div style='font-size: 200%;'>";
    -	echo "<p style='color: red'>";
    +	echo "<br><div class='errorMsgBig errorMsgBox'>";
     	echo "The installation of the WebUI is incomplete.<br>";
     	echo "File '$defs' not found.<br>";
    -	echo "Please run the following from the 'allsky' directory:";
    -	echo "</p>";
    -	echo "<code>   ./install.sh --function create_webui_defines</code>";
    +	echo "Please run the following to fix:";
    +	echo "<br><br>";
    +	echo "<code>   cd ~/allsky; ./install.sh --function create_webui_defines</code>";
     	echo "</div>";
    -	exit;
    +	exit(1);
     }
     
     // Read and decode a json file, returning the decoded results or null.
    -// On error, display the specified error message
    +// On error, display the specified error message.
    +// If we're being run by the user it's likely on a tty so don't use html.
     function get_decoded_json_file($file, $associative, $errorMsg, &$returnedMsg=null) {
    -	if (! file_exists($file)) {
    -		$retMsg  = "<div style='color: red; font-size: 200%;'>";
    -		$retMsg .= "$errorMsg";
    -		$retMsg .= "<br>File '$file' missing!";
    -		$retMsg .= "</div>";
    +	$retMsg = "";
    +	$html = (get_current_user() == WEBSERVER_OWNER);
    +	if ($html) {
    +		$div = "<div class='errorMsgBig'>";
    +		$end = "</div>";
    +		$br = "<br>";
    +		$sep = "<br>";
    +	} else {
    +		$div = "";
    +		$end = "\n";
    +		$br = "\n";
    +		$sep = "\n";
    +	}
    +
    +	if ($file == "" || ! file_exists($file)) {
    +		$retMsg .= $div;
    +		$retMsg .= $errorMsg;
    +		if ($file == "")
    +			$retMsg .= " File not specified!";
    +		else
    +			$retMsg .= " File <b>$file</b> not found!";
    +		$retMsg .= $end;
     		if ($returnedMsg === null) echo "$retMsg";
     		else $returnedMsg = $retMsg;
     		return null;
     	}
     
     	$str = file_get_contents($file, true);
    -	if ($str === "") {
    -		$retMsg  = "<div style='color: red; font-size: 200%;'>";
    -		$retMsg .= "$errorMsg";
    -		$retMsg .= "<br>File '$file' is empty!";
    -		$retMsg .= "</div>";
    -		if ($returnedMsg === null) echo "$retMsg";
    -		else $returnedMsg = $retMsg;
    -		return null;
    -	} else if ($str === false) {
    -		$retMsg  = "<div style='color: red; font-size: 200%;'>";
    -		$retMsg .= "$errorMsg:";
    -		$retMsg .= "<br>Error reading '$file'!";
    -		$retMsg .= "</div>";
    +	if ($str === "" || $str === false) {
    +		$retMsg .= $div;
    +		$retMsg .= $errorMsg;
    +		if ($str === "")
    +			$retMsg .= " File <b>$file</b> is empty!";
    +		else
    +			$retMsg .= " Error reading <b>$file</b>!";
    +		$retMsg .= $end;
     		if ($returnedMsg === null) echo "$retMsg";
     		else $returnedMsg = $retMsg;
     		return null;
    @@ -55,13 +67,14 @@ function get_decoded_json_file($file, $associative, $errorMsg, &$returnedMsg=nul
     
     	$str_array = json_decode($str, $associative);
     	if ($str_array == null) {
    -		$retMsg  = "<div style='color: red; font-size: 200%;'>";
    -		$retMsg .= "$errorMsg";
    -		$retMsg .= "<br>" . json_last_error_msg();
    +		$retMsg .= $div;
    +		$retMsg .= "$errorMsg ";
    +		$retMsg .= json_last_error_msg();
     		$cmd = "json_pp < $file 2>&1";
     		exec($cmd, $output);
    -		$retMsg .= "<br>" . implode("<br>", $output);
    -		$retMsg .= "</div>";
    +		$retMsg .= $br;
    +		$retMsg .= implode($sep, $output);
    +		$retMsg .= $end;
     		if ($returnedMsg === null) echo "$retMsg";
     		else $returnedMsg = $retMsg;
     		return null;
    @@ -69,132 +82,200 @@ function get_decoded_json_file($file, $associative, $errorMsg, &$returnedMsg=nul
     	return $str_array;
     }
     
    -$image_name=null; $delay=null; $daydelay=null; $nightdelay=null; $darkframe=null; $useLogin=null;
    -$temptype = null;
    -$lastChanged = null;
    -$websiteURL = null;
    -function initialize_variables() {
    -	global $status, $needToDisplayMessages;
    -	global $image_name, $delay, $daydelay, $nightdelay;
    -	global $darkframe, $useLogin, $temptype, $lastChanged, $lastChangedName;
    -	global $websiteURL;
    -
    -	// The Camera Type should be set during the installation, so this "should" never fail...
    -	$cam_type = getCameraType();
    -	if ($cam_type == '') {
    -		echo "<div style='color: red; font-size: 200%;'>";
    -		echo "'Camera Type' not defined in config.sh.  Please update it.";
    -		echo "</div>";
    -		exit;
    +// The opposite of toString().  Given a string version of a boolean, return true or false.
    +function toBool($s) {
    +	if ($s == "true" || $s == "Yes" || $s == "yes" || $s == "1")
    +		return true;
    +	return false;
    +}
    +
    +function verifyNumber($num) {
    +	if ($num == "" || ! is_numeric($num)) {
    +		return false;
     	}
    +	return true;
    +}
     
    +// Globals
    +$image_name = null;
    +$showUpdatedMessage = true; $delay=null; $daydelay=null; $daydelay_postMsg=""; $nightdelay=null; $nightdelay_postMsg="";
    +$imagesSortOrder = null;
    +$darkframe = null;
    +$useLogin = null;
    +$temptype = null;
    +$lastChanged = null;
    +$remoteWebsiteURL = null;
    +$settings_array = null;
    +$useLocalWebsite = false;
    +$useRemoteWebsite = false;
    +$hasLocalWebsite = false;
    +$hasRemoteWebsite = false;
    +$endSetting = "XX_END_XX";
    +
    +function readSettingsFile() {
     	$settings_file = getSettingsFile();
     	$errorMsg = "ERROR: Unable to process settings file '$settings_file'.";
    -	$settings_array = get_decoded_json_file($settings_file, true, $errorMsg);
    -	if ($settings_array === null) {
    -		exit;
    +	$contents = get_decoded_json_file($settings_file, true, $errorMsg);
    +	if ($contents === null) {
    +		exit(1);
     	}
    +	return($contents);
    +}
    +
    +function readOptionsFile() {
    +	$options_file = getOptionsFile();
    +	$errorMsg = "ERROR: Unable to process options file '$options_file'.";
    +	$contents = get_decoded_json_file($options_file, true, $errorMsg);
    +	if ($contents === null) {
    +		exit(1);
    +	}
    +	return($contents);
    +}
    +
    +function initialize_variables($website_only=false) {
    +	global $status;
    +	global $image_name;
    +	global $showUpdatedMessage, $delay, $daydelay, $daydelay_postMsg, $nightdelay, $nightdelay_postMsg;
    +	global $imagesSortOrder;
    +	global $darkframe, $useLogin, $temptype, $lastChanged, $lastChangedName;
    +	global $remoteWebsiteURL;
    +	global $settings_array;
    +	global $useLocalWebsite, $useRemoteWebsite;
    +	global $hasLocalWebsite, $hasRemoteWebsite;
    +
    +	$settings_array = readSettingsFile();
    +
    +	// See if there are any Website configuration files.
    +	// The "has" variables just mean the associated configuration file exists,
    +	// and in the case of a remote Website, that it also has a URL.
    +	// The "use" variables means we're actually using the Website.
    +	if (file_exists(getLocalWebsiteConfigFile())) {
    +		$hasLocalWebsite = true;
    +		$useLocalWebsite = toBool(getVariableOrDefault($settings_array, 'uselocalwebsite', "false"));
    +	}
    +	if (file_exists(getRemoteWebsiteConfigFile()) && getVariableOrDefault($settings_array, "remotewebsiteurl", "") !== "") {
    +		$hasRemoteWebsite = true;
    +		$useRemoteWebsite = toBool(getVariableOrDefault($settings_array, 'useremotewebsite', "false"));
    +	}
    +
    +	if ($website_only) return;
     
     	// $img_dir is an alias in the web server's config that points to where the current image is.
     	// It's the same as ${ALLSKY_TMP} which is the physical path name on the server.
    -	$img_dir = get_variable(ALLSKY_CONFIG . '/config.sh', 'IMG_DIR=', 'current/tmp');
    -	$image_name = $img_dir . "/" . $settings_array['filename'];
    -	$darkframe = $settings_array['takeDarkFrames'];
    -	$useLogin = getVariableOrDefault($settings_array, 'useLogin', true);
    +	$img_dir = get_variable(ALLSKY_HOME . '/variables.sh', 'IMG_DIR=', 'current/tmp');
    +	$f = getVariableOrDefault($settings_array, 'filename', "image.jpg");
    +	$image_name = "$img_dir/$f";
    +	$darkframe = toBool(getVariableOrDefault($settings_array, 'takedarkframes', "false"));
    +	$imagesSortOrder = getVariableOrDefault($settings_array, 'imagessortorder', "ascending");
    +	$useLogin = toBool(getVariableOrDefault($settings_array, 'uselogin', "true"));
     	$temptype = getVariableOrDefault($settings_array, 'temptype', "C");
     	$lastChanged = getVariableOrDefault($settings_array, $lastChangedName, "");
    -	$websiteURL = getVariableOrDefault($settings_array, 'websiteurl', "");
    +	$remoteWebsiteURL = getVariableOrDefault($settings_array, 'remotewebsiteurl', "");
     
    +	$ms_per_sec = 1000;		// to make the code easier to read
     
     	////////////////// Determine delay between refreshes of the image.
    -	$consistentDelays = $settings_array["consistentDelays"] == 1 ? true : false;
    -	$daydelay = $settings_array["daydelay"];
    -	$daymaxautoexposure = $settings_array["daymaxautoexposure"];
    -	$dayexposure = $settings_array["dayexposure"];
    -	$nightdelay = $settings_array["nightdelay"];
    -	$nightmaxautoexposure = $settings_array["nightmaxautoexposure"];
    -	$nightexposure = $settings_array["nightexposure"];
    +	$daydelay = getVariableOrDefault($settings_array, 'daydelay', 30 * $ms_per_sec);
    +	$nightdelay = getVariableOrDefault($settings_array, 'nightdelay', 30 * $ms_per_sec);
    +	$showUpdatedMessage = toBool(getVariableOrDefault($settings_array, 'showupdatedmessage', "true"));
    +
    +	$dayexposure = getVariableOrDefault($settings_array, 'dayexposure', 500);
    +	$daymaxautoexposure = getVariableOrDefault($settings_array, 'daymaxautoexposure', 100);
    +	$nightexposure = getVariableOrDefault($settings_array, 'nightexposure', 10 * $ms_per_sec);
    +	$nightmaxautoexposure = getVariableOrDefault($settings_array, 'nightmaxautoexposure', 10 * $ms_per_sec);
     
     	$ok = true;
    -	if (! is_numeric($daydelay)) {
    -		$ok = false;
    -		$status->addMessage("<strong>daydelay</strong> is not a number.", 'danger', false);
    -	}
    -	if (! is_numeric($daymaxautoexposure)) {
    -		$ok = false;
    -		$status->addMessage("<strong>daymaxautoexposure</strong> is not a number.", 'danger', false);
    -	}
    -	if (! is_numeric($dayexposure)) {
    -		$ok = false;
    -		$status->addMessage("<strong>dayexposure</strong> is not a number.", 'danger', false);
    -	}
    -	if (! is_numeric($nightdelay)) {
    -		$ok = false;
    -		$status->addMessage("<strong>nightdelay</strong> is not a number.", 'danger', false);
    -	}
    -	if (! is_numeric($nightmaxautoexposure)) {
    -		$ok = false;
    -		$status->addMessage("<strong>nightmaxautoexposure</strong> is not a number.", 'danger', false);
    +	// These are all required settings so if they are blank don't display a
    +	// message since the WebUI will.
    +	$delay = 0;
    +	if (! verifyNumber($daydelay)) $ok = false; else $delay += $daydelay;
    +	if (! verifyNumber($daymaxautoexposure)) $ok = false;
    +	if (! verifyNumber($dayexposure)) $ok = false;
    +	if (! verifyNumber($nightdelay)) $ok = false; else $delay += $nightdelay;
    +	if (! verifyNumber($nightmaxautoexposure)) $ok = false;
    +	if (! verifyNumber($nightexposure)) $ok = false;
    +
    +	if (! $ok) {
    +		$showUpdatedMessage = false;
    +		if ($delay === 0) $delay = 20 * $ms_per_sec;	// a reasonable default
    +		return;
     	}
    -	if (! is_numeric($nightexposure)) {
    -		$ok = false;
    -		$status->addMessage("<strong>nightexposure</strong> is not a number.", 'danger', false);
    +
    +	$dayautoexposure = toBool(getVariableOrDefault($settings_array, 'dayautoexposure', "true"));
    +	$nightautoexposure = toBool(getVariableOrDefault($settings_array, 'nightautoexposure', "true"));
    +	$consistentDelays = toBool(getVariableOrDefault($settings_array, 'consistentdelays', "true"));
    +
    +	if ($consistentDelays) {
    +		$daydelay += $dayautoexposure ?  $daymaxautoexposure : $dayexposure;
    +		$daydelay_postMsg = "";
    +		$nightdelay += $nightautoexposure ?   $nightmaxautoexposure : $nightexposure;
    +		$nightdelay_postMsg = "";
    +	} else {
    +		// Using $daymaxautoexposure and $nightmaxautoexposure isn't
    +		// accurate since they are fixed numbers.
    +		// If the ACTUAL exposure was, e.g., 1 us, then the actual delay is effectively just the delay,
    +		// but if $dayexposure was 10 seconds, we'd set the delay to $delay + 10 seconds.
    +		// Daytime exposure are normally under a second, so use 1 second for the auto-exposure amount.
    +		// Daytime exposure are normally at least 10 seconds so use that.
    +		$daydelay += $dayautoexposure ?  (1 * $ms_per_sec) : $dayexposure;
    +		$daydelay_postMsg = " minimum";
    +		$nightdelay += $nightautoexposure ?   (10 * $ms_per_sec) : $nightexposure;
    +		$nightdelay_postMsg = " minimum";
     	}
    -	if ($ok) {
    -		$daydelay += ($consistentDelays ? $daymaxautoexposure : $dayexposure);
    -		$nightdelay += ($consistentDelays ? $nightmaxautoexposure : $nightexposure);
    -
    -		$showDelay = getVariableOrDefault($settings_array, 'showDelay', true);
    -		if ($showDelay) {
    -			// Determine if it's day or night so we know which delay to use.
    -			$angle = $settings_array['angle'];
    -			$lat = $settings_array['latitude'];
    -			$lon = $settings_array['longitude'];
    -			exec("sunwait poll exit set angle $angle $lat $lon", $return, $retval);
    -			if ($retval == 2) {
    -				$delay = $daydelay;
    -			} else if ($retval == 3) {
    -				$delay = $nightdelay;
    -			} else {
    -				$msg = "<code>sunwait</code> returned $retval; don't know if it's day or night.";
    -				$status->addMessage($msg, 'danger', false);
    -				$needToDisplayMessages = true;
    -				$delay = ($daydelay + $nightdelay) / 2;		// Use the average delay
    -			}
     
    -			// Convert to seconds for display.
    -			$daydelay /= 1000;
    -			$nightdelay /= 1000;
    +	// Determine if it's day or night so we know which delay to use.
    +	$angle = getVariableOrDefault($settings_array, 'angle', -6);
    +	$lat = getVariableOrDefault($settings_array, 'latitude', "");
    +	$lon = getVariableOrDefault($settings_array, 'longitude', "");
    +	if ($lat != "" && $lon != "") {
    +		exec("sunwait poll exit set angle $angle $lat $lon", $return, $retval);
    +		if ($retval == 2) {
    +			$delay = $daydelay;
    +		} else if ($retval == 3) {
    +			$delay = $nightdelay;
     		} else {
    -			// Not showing delay so just use average
    +			$msg = "<code>sunwait</code> returned $retval; don't know if it's day or night.";
    +			$status->addMessage($msg, 'danger');
     			$delay = ($daydelay + $nightdelay) / 2;		// Use the average delay
    -			$daydelay = -1;		// signifies it's not being used
     		}
    -		// Lessen the delay between a new picture and when we check.
    -		$delay /= 4;
    +
    +		// Convert to seconds for display on the LiveView page.
    +		// These variables are now only used for the display.
    +		$daydelay /= $ms_per_sec;
    +		$nightdelay /= $ms_per_sec;
     	} else {
    -		$daydelay = -1;
    -		$needToDisplayMessages = true;
    +		// Error message will be displayed by WebUI.
    +		$showUpdatedMessage = false;
    +		// Not showing delay so just use average
    +		$delay = ($daydelay + $nightdelay) / 2;		// Use the average delay
     	}
    +
    +	// Lessen the delay between a new picture and when we check.
    +	$delay /= 5;
    +	$delay = max($delay, 2 * $ms_per_sec);
     }
     
     // Check if the settings have been configured.
     function check_if_configured($page, $calledFrom) {
    -	global $lastChanged, $status, $needToDisplayMessages;
    +	global $lastChanged, $status;
    +	static $will_display_configured_message = false;
     
    -	// The conf page calls us if needed.
    -	if ($calledFrom === "main" && $page === "configuration")
    -		return;
    +	if ($will_display_configured_message)
    +		return(true);
     
     	if ($lastChanged === "") {
     		// The settings aren't configured - probably right after an installation.
    +		$msg = "Allsky must be configured before using it.<br>";
     		if ($page === "configuration")
    -			$m = "";
    +			$msg .= "If it's already configured, just click on the 'Save changes' button.";
     		else
    -			$m = "<br>Go to the 'Allsky Settings' page.";
    -		$status->addMessage("You must configure Allsky before using it.<br>If it's already configured, just click on the 'Save changes' button.$m", 'danger', false);
    -		$needToDisplayMessage = true;
    +			$msg .= "Go to the 'Allsky Settings' page to do so.";
    +		$status->addMessage("<div id='mustConfigure' class='important'>$msg</div>", 'danger');
    +		$will_display_configured_message = true;
    +		return(false);
     	}
    +
    +	return(true);
     }
     /**
     *
    @@ -289,7 +370,7 @@ function ParseConfig( $arrConfig ) {
     	foreach( $arrConfig as $line ) {
     		$line = trim($line);
     		if( $line != "" && $line[0] != "#" ) {
    -			$arrLine = explode( "=",$line );
    +			$arrLine = explode( "=", $line );
     			$config[$arrLine[0]] = ( count($arrLine) > 1 ? $arrLine[1] : true );
     		}
     	}
    @@ -389,7 +470,7 @@ function parse_ifconfig($input, &$strHWAddress, &$strIPAddress, &$strNetMask, &$
     	}
     }
     
    -function handle_interface_POST_and_status($interface, $input, &$status) {
    +function handle_interface_POST_and_status($interface, $input, &$myStatus) {
     	$interface_up = false;
     	if( isset($_POST['turn_down']) ) {
     		// We should only get here if the interface is up,
    @@ -397,19 +478,19 @@ function handle_interface_POST_and_status($interface, $input, &$status) {
     		// If the interface is down it's also not running.
     		$s = get_interface_status("ifconfig $interface");
     		if (! is_interface_up($s)) {
    -			$status->addMessage("Interface $interface was already down", 'warning');
    +			$myStatus->addMessage("Interface $interface was already down", 'warning', false);
     		} else {
     			exec( "sudo ifconfig $interface down 2>&1", $output );	// stop
     			// Check that it actually stopped
     			$s = get_interface_status("ifconfig $interface");
     			if (! is_interface_up($s)) {
    -				$status->addMessage("Interface $interface stopped", 'success');
    +				$myStatus->addMessage("Interface $interface stopped", 'success', false);
     			} else {
     				if ($output == "")
     					$output = "Unknown reason";
     				else
     					$output = implode(" ", $output);
    -				$status->addMessage("Unable to stop interface $interface<br>$output" , 'danger');
    +				$myStatus->addMessage("Unable to stop interface $interface<br>$output" , 'danger', false);
     				$interface_up = true;
     			}
     		}
    @@ -418,19 +499,19 @@ function handle_interface_POST_and_status($interface, $input, &$status) {
     		// We should only get here if the interface is down,
     		// but just in case, check if it's already up.
     		if (is_interface_up(get_interface_status("ifconfig $interface"))) {
    -			$status->addMessage("Interface $interface was already up", 'warning');
    +			$myStatus->addMessage("Interface $interface was already up", 'warning', false);
     			$interface_up = true;
     		} else {
     			exec( "sudo ifconfig $interface up 2>&1", $output );	// start
     			// Check that it actually started
     			$s = get_interface_status("ifconfig $interface");
     			if (! is_interface_up($s)) {
    -				$status->addMessage("Unable to start interface $interface", 'danger');
    +				$myStatus->addMessage("Unable to start interface $interface", 'danger', false);
     			} else {
     				if (is_interface_running($s))
    -					$status->addMessage("Interface $interface started", 'success');
    +					$myStatus->addMessage("Interface $interface started", 'success', false);
     				else
    -					$status->addMessage("Interface $interface started but nothing connected to it", 'warning');
    +					$myStatus->addMessage("Interface $interface started but nothing connected to it", 'warning', false);
     				$interface_up = true;
     			}
     		}
    @@ -438,13 +519,13 @@ function handle_interface_POST_and_status($interface, $input, &$status) {
     	} elseif (is_interface_up($input)) {
     		// The interface can be up but nothing connected to it (i.e., not RUNNING).
     		if (is_interface_running($input))
    -			$status->addMessage("Interface $interface is up", 'success');
    +			$myStatus->addMessage("Interface $interface is up", 'success', false);
     		else
    -			$status->addMessage("Interface $interface is up but nothing connected to it", 'warning');
    +			$myStatus->addMessage("Interface $interface is up but nothing connected to it", 'warning', false);
     		$interface_up = true;
     
     	} else {
    -		$status->addMessage("Interface $interface is down", 'danger');
    +		$myStatus->addMessage("Interface $interface is down", 'danger', false);
     	}
     
     	return($interface_up);
    @@ -458,14 +539,19 @@ function handle_interface_POST_and_status($interface, $input, &$status) {
     * so there shouldn't be a comment on the line,
     * however, there can be optional spaces or tabs before the string.
     *
    -* This function will go away once the config.sh and ftp-settings.sh files are merged
    -* into the settings.json file.
     */
     function get_variable($file, $searchfor, $default)
     {
     	// get the file contents
    +	if (! file_exists($file)) {
    +		$msg  = "<div class='error-msg'>";
    +		$msg .= "<br>File '$file' not found!";
    +		$msg .= "</div>";
    +		echo $msg;
    +		return($default);
    +	}
     	$contents = file_get_contents($file);
    -	if ("$contents" == "") return($default);	// file not found or not readable
    +	if ($contents == "") return($default);	// file not found or not readable
     
     	// escape special characters in the query
     	$pattern = preg_quote($searchfor, '/');
    @@ -501,8 +587,9 @@ function get_variable($file, $searchfor, $default)
     /**
     * 
     * List a type of file - either "All" (case sensitive) for all days, or only for the specified day.
    +* If $dir is not null, it ends in "/".
     */
    -function ListFileType($dir, $imageFileName, $formalImageTypeName, $type) {	// if $dir is not null, it ends in "/"
    +function ListFileType($dir, $imageFileName, $formalImageTypeName, $type) {
     	$num = 0;	// Let the user know when there are no images for the specified day
     	// "/images" is an alias in the web server for ALLSKY_IMAGES
     	$images_dir = "/images";
    @@ -537,16 +624,16 @@ function ListFileType($dir, $imageFileName, $formalImageTypeName, $type) {	// if
     						$fullFilename = "$images_dir/$day/$dir$imageType_name";
     						if ($type == "picture") {
     							echo "<a href='$fullFilename'>";
    -							echo "<div style='float: left; width: 100%; margin-bottom: 2px;'>";
    +							echo "<div class='functionsListFileType'>";
     							echo "<label>$day</label>";
    -							echo "<img src='$fullFilename' style='margin-left: 10px; max-width: 50%; max-height:100px'/>";
    +							echo "<img src='$fullFilename' class='functionsListTypeImg' />";
     							echo "</div></a>\n";
     						} else {	// is video
    -							// xxxx TODO: Show a thumbnail since loading all the videos is bandwidth intensive.
    +							// TODO: Show a thumbnail since loading videos is bandwidth intensive.
     							echo "<a href='$fullFilename'>";
    -							echo "<div style='float: left; width: 100%; margin-bottom: 2px;'>";
    -							echo "<label style='vertical-align: middle'>$day &nbsp; &nbsp;</label>";
    -							echo "<video width='85%' height='85%' controls style='vertical-align: middle'>";
    +							echo "<div class='functionsListFileType'>";
    +							echo "<label class='middleVerticalAlign'>$day &nbsp; &nbsp;</label>";
    +							echo "<video width='85%' height='85%' controls class='middleVerticalAlign'>";
     							echo "<source src='$fullFilename' type='video/mp4'>";
     							echo "Your browser does not support the video tag.";
     							echo "</video>";
    @@ -572,12 +659,12 @@ function ListFileType($dir, $imageFileName, $formalImageTypeName, $type) {	// if
     				$fullFilename = "$images_dir/$chosen_day/$dir$imageType_name";
     				if ($type == "picture") {
     				    echo "<a href='$fullFilename'>
    -					<div style='float: left'>
    -					<img src='$fullFilename' style='max-width: 100%;max-height:400px'/>
    +					<div class='left'>
    +					<img src='$fullFilename' style='max-width: 100%; max-height: 400px'/>
     					</div></a>\n";
     				} else {	//video
     				    echo "<a href='$fullFilename'>";
    -				    echo "<div style='float: left; width: 100%'>
    +				    echo "<div class='left' style='width: 100%'>
     					<video width='85%' height='85%' controls>
     						<source src='$fullFilename' type='video/mp4'>
     						Your browser does not support the video tag.
    @@ -592,16 +679,18 @@ function ListFileType($dir, $imageFileName, $formalImageTypeName, $type) {	// if
     
     // Run a command and display the appropriate status message.
     // If $addMsg is false, then don't add our own message.
    -function runCommand($cmd, $message, $messageColor, $addMsg=true)
    +function runCommand($cmd, $onSuccessMessage, $messageColor, $addMsg=true, $onFailureMessage="")
     {
     	global $status;
     
     	exec("$cmd 2>&1", $result, $return_val);
    +	$dq = '"';
    +	echo "<script>console.log(${dq}[$cmd] returned $return_val, result=" . implode(" ", $result) . "${dq});</script>";
     	if ($return_val === 255) {
     		// This is only a warning so only display the caller's message, if any.
     		if ($result != null) $msg = implode("<br>", $result);
     		else $msg = "";
    -		$status->addMessage($msg, "warning", true);
    +		$status->addMessage($msg, "warning", false);
     		return false;
     	} elseif ($return_val > 0) {
     		// Display a failure message, plus the caller's message, if any.
    @@ -612,16 +701,33 @@ function runCommand($cmd, $message, $messageColor, $addMsg=true)
     			if ($result != null) $msg = implode("<br>", $result);
     			else $msg = "";
     		}
    -		$status->addMessage($msg, "danger", true);
    +		// Display the caller's "on success" onSuccessMessage, if any.
    +		if ($onFailureMessage !== "")
    +			$status->addMessage($onFailureMessage, "danger", false);
    +		$status->addMessage($msg, "danger", false);
     		return false;
     	}
     
    -	// Display the caller's "on success" message, if any.
    -	if ($message !== "")
    -		$status->addMessage($message, $messageColor, true);
    +	// Display the caller's "on success" onSuccessMessage, if any.
    +	if ($onSuccessMessage !== "")
    +		$status->addMessage($onSuccessMessage, $messageColor, false);
     
     	// Display any output from the command.
    -	if ($result != null) $status->addMessage(implode("<br>", $result), "message", true);
    +	// If there are any lines that begin with:  ERROR  or  WARNING
    +	// then display them in the appropriate format.
    +	if ($result != null) {
    +		//x $status->addMessage(implode("<br>", $result), "message", false);
    +  		foreach ( $result as $line) {
    +			if (strpos($line, "ERROR:") !== false) {
    +				$sev = "danger";
    +			} else if (strpos($line, "WARNING:") !== false) {
    +				$sev = "warning";
    +			} else {
    +				$sev = "message";
    +			}
    +			$status->addMessage("$line<br>", $sev, false);
    +		}
    +	}
     
     	return true;
     }
    @@ -635,29 +741,37 @@ function updateFile($file, $contents, $fileName, $toConsole) {
     
     		// $toConsole tells us whether or not to use console.log() or just echo.
     		if ($toConsole) {
    -			$cl1 = "<script>console.log('";
    -			$cl2 = "');</script>";
    +			$cl1 = '<script>console.log("';
    +			$cl2 = '");</script>';
     		} else {
    -			$cl1 = "";
    +			$cl1 = "<br>";
     			$cl2 = "";
     		}
    -		echo $cl1 . "Unable to update $file 1st time: $e$cl2\n";
    +		echo "${cl1}Note: Unable to update $file 1st time: ${e}${cl2}\n";
     
     		// Assumed it failed due to lack of permissions,
     		// usually because the file isn't grouped to the web server group.
     		// Set the permissions and try again.
     
    -		$err = str_replace("\n", "", shell_exec("x=\$(sudo chgrp " . WEBSERVER_GROUP . " '$file' 2>&1 && sudo chmod g+w '$file') || echo \${x}"));
    -		if ($err != "") {
    +		$cmd = "sudo touch '$file' && sudo chgrp " . WEBSERVER_GROUP . " '$file' &&";
    +		$cmd .= " sudo chmod g+w '$file'";
    +		$return = null;
    +		$ret = exec("( $cmd ) 2>&1", $return, $retval);
    +		if (gettype($return) === "array")
    +			$c = count($return);
    +		else
    +			$c = 0;
    +		if ($ret === false || $c > 0 || $retval !== 0) {
    +			$err = implode("\n", $return);
     			return "Unable to update settings: $err";
     		}
     
     		if (@file_put_contents($file, $contents) == false) {
     			$e = error_get_last()['message'];
     			$err = "Failed to save settings: $e";
    -			echo $cl1 . "Unable to update file for 2nd time: $e$cl2";
    +			echo "${cl1}Unable to update file for 2nd time: ${e}${cl2}";
     			$x = str_replace("\n", "", shell_exec("ls -l '$file'"));
    -			echo $cl1 . "ls -l returned: $x$cl2";
    +			echo "${cl1}ls -l returned: ${x}${cl2}";
     
     			// Save a temporary copy of the file in a place the webserver can write to,
     			// then use sudo to "cp" the file to the final place.
    @@ -670,42 +784,96 @@ function updateFile($file, $contents, $fileName, $toConsole) {
     				return $err;
     			}
     
    -			$err = str_replace("\n", "", shell_exec("x=\$(sudo cp '$tempFile' '$file' 2>&1) || echo 'Unable to copy [$tempFile] to [$file]': \${x}"));
    -			echo $cl1 . "cp returned: [$err]$cl2";
    +			$cmd = "x=\$(sudo cp '$tempFile' '$file' 2>&1) || echo 'Unable to copy [$tempFile] to [$file]': \${x}";
    +			$err = str_replace("\n", "", shell_exec($cmd));
    +			if ($err !== "") echo "${cl1}cp returned: [$err]${cl2}";
     			return $err;
     		}
     	}
     	return "";
     }
     
    -function getCameraType() {
    -	return get_variable(ALLSKY_CONFIG . '/config.sh', 'CAMERA_TYPE=', '');
    -}
    -
    -// Return the settings file for the specified camera.
    +// Return the settings file for the current camera.
     function getSettingsFile() {
     	return ALLSKY_CONFIG . "/settings.json";
     }
     
    -// Return the options file for the specified camera.
    +// Return the options file for the current camera.
     function getOptionsFile() {
     	return ALLSKY_CONFIG . "/options.json";
     }
     
    +// Return the full path name of the local Website configuration file.
    +function getLocalWebsiteConfigFile() {
    +	return ALLSKY_WEBSITE_LOCAL_CONFIG;
    +}
    +
    +// Return the full path name of the remote Website configuration file.
    +function getRemoteWebsiteConfigFile() {
    +	return ALLSKY_WEBSITE_REMOTE_CONFIG;
    +}
    +
    +// Return the file name after accounting for any ${} variables.
    +// Since there will often only be one file used by multiple settings,
    +// as an optimization save the last name.
    +$lastFileName = null;
    +function getFileName($file) {
    +	global $lastFileName;
    +
    +	if ($lastFileName === $file) return $lastFileName;
    +
    +	if (strpos('${HOME}', $file) !== false) {
    +		$lastFileName = str_replace('${HOME}', HOME, $file);
    +	} else if (strpos('${ALLSKY_ENV}', $file) !== false) {
    +		$lastFileName = str_replace('${ALLSKY_ENV}', ALLSKY_ENV, $file);
    +	} else if (strpos('${ALLSKY_HOME}', $file) !== false) {
    +		$lastFileName = str_replace('${ALLSKY_HOME}', ALLSKY_HOME, $lastFileName);
    +	}
    +	return $lastFileName;
    +}
    +
     // Check if the specified variable is in the specified array.
     // If so, return it; if not, return default value;
     // This is used to make the code easier to read.
     function getVariableOrDefault($a, $v, $d) {
     	if (isset($a[$v])) {
     		$value = $a[$v];
    -		if (gettype($value) === "boolean" && $value == "") return 0;
    +		if (gettype($value) === "boolean") {
    +			if ($value || $value == "true") {
    +				return "true";
    +			} else {
    +				return "false";
    +			}
    +		}
     		return $value;
     	} else if (gettype($d) === "boolean" && $d == "") {
    -		return 0;
    +		return "false";
     	} else if (gettype($d) === "null") {
     		return null;
     	}
     
     	return($d);
     }
    +
    +function getTOD() {
    +	global $settings_array;
    +
    +	//$settings_array = readSettingsFile();
    +
    +	$angle = getVariableOrDefault($settings_array, 'angle', -6);
    +	$lat = getVariableOrDefault($settings_array, 'latitude', "");
    +	$lon = getVariableOrDefault($settings_array, 'longitude', "");
    +	$tod = 'Unknown';
    +
    +	if ($lat != "" && $lon != "") {
    +		exec("sunwait poll exit set angle $angle $lat $lon", $return, $retval);
    +		if ($retval == 2) {
    +			$tod = 'day';
    +		} else if ($retval == 3) {
    +			$tod = 'night';
    +		}
    +	}
    +	
    +	return $tod;
    +}
     ?>
    diff --git a/html/includes/hostapd.php b/html/includes/hostapd.php
    index a3f5faba1..31bbb61a1 100644
    --- a/html/includes/hostapd.php
    +++ b/html/includes/hostapd.php
    @@ -1,10 +1,8 @@
     <?php
     
    -include_once( 'includes/status_messages.php' );
    -
     function DisplayHostAPDConfig(){
       global $page;
    -  $status = new StatusMessages();
    +  $myStatus = new StatusMessages();
     
       $arrConfig = array();
       $arrChannel = array('a','b','g');
    @@ -15,26 +13,26 @@ function DisplayHostAPDConfig(){
     
       if( isset($_POST['SaveHostAPDSettings']) ) {
         if (CSRFValidate()) {
    -      SaveHostAPDConfig($arrSecurity, $arrEncType, $arrChannel, $interfaces, $status);
    +      SaveHostAPDConfig($arrSecurity, $arrEncType, $arrChannel, $interfaces, $myStatus);
         } else {
           error_log('CSRF violation');
         }
       } elseif( isset($_POST['StartHotspot']) ) {
         if (CSRFValidate()) {
    -      $status->addMessage('Attempting to start hotspot', 'info');
    +      $myStatus->addMessage('Attempting to start hotspot', 'info');
           exec( 'sudo /etc/init.d/hostapd start', $return );
           foreach( $return as $line ) {
    -        $status->addMessage($line, 'info');
    +        $myStatus->addMessage($line, 'info');
           }
         } else {
           error_log('CSRF violation');
         }
       } elseif( isset($_POST['StopHotspot']) ) {
         if (CSRFValidate()) {
    -      $status->addMessage('Attempting to stop hotspot', 'info');
    +      $myStatus->addMessage('Attempting to stop hotspot', 'info');
           exec( 'sudo /etc/init.d/hostapd stop', $return );
           foreach( $return as $line ) {
    -        $status->addMessage($line, 'info');
    +        $myStatus->addMessage($line, 'info');
           }
         } else {
           error_log('CSRF violation');
    @@ -45,9 +43,9 @@ function DisplayHostAPDConfig(){
       exec( 'pidof hostapd | wc -l', $hostapdstatus);
     
       if( $hostapdstatus[0] == 0 ) {
    -    $status->addMessage('HostAPD is not running', 'warning');
    +    $myStatus->addMessage('HostAPD is not running', 'warning');
       } else {
    -    $status->addMessage('HostAPD is running', 'success');
    +    $myStatus->addMessage('HostAPD is running', 'success');
       }
     
       foreach( $return as $a ) {
    @@ -60,10 +58,10 @@ function DisplayHostAPDConfig(){
       <div class="row">
         <div class="col-lg-12">
           <div class="panel panel-primary">
    -        <div class="panel-heading"><i class="fa fa-dot-circle-o fa-fw"></i> Configure Hotspot</div>
    +        <div class="panel-heading"><i class="fa fa-dot-circle fa-fw"></i> Configure Hotspot</div>
             <!-- /.panel-heading -->
             <div class="panel-body">
    -	  <?php if ($status->isMessage()) echo "<p>" . $status->showMessages() . "</p>"; ?>
    +          <?php if ($myStatus->isMessage()) echo "<p>" . $myStatus->showMessages() . "</p>"; ?>
               <form role="form" action="?page=<?php echo $page ?>" method="POST">
                 <!-- Nav tabs -->
                 <ul class="nav nav-tabs">
    @@ -134,7 +132,7 @@ function DisplayHostAPDConfig(){
                       <input type="hidden" id="selected_country" value="<?php echo $arrConfig['country_code'] ?>">
                       <select  class="form-control"  id="countries" name="country_code"  style="background-color: #c0ffc0;">
                         <option value="AF">Afghanistan</option>
    -                    <option value="AX">Ã…land Islands</option>
    +                    <option value="AX">Ã…land Islands</option>
                         <option value="AL">Albania</option>
                         <option value="DZ">Algeria</option>
                         <option value="AS">American Samoa</option>
    @@ -187,10 +185,10 @@ function DisplayHostAPDConfig(){
                         <option value="CD">Congo, the Democratic Republic of the</option>
                         <option value="CK">Cook Islands</option>
                         <option value="CR">Costa Rica</option>
    -                    <option value="CI">Côte d'Ivoire</option>
    +                    <option value="CI">Côte d'Ivoire</option>
                         <option value="HR">Croatia</option>
                         <option value="CU">Cuba</option>
    -                    <option value="CW">Curaçao</option>
    +                    <option value="CW">Curaçao</option>
                         <option value="CY">Cyprus</option>
                         <option value="CZ">Czech Republic</option>
                         <option value="DK">Denmark</option>
    @@ -313,11 +311,11 @@ function DisplayHostAPDConfig(){
                         <option value="PT">Portugal</option>
                         <option value="PR">Puerto Rico</option>
                         <option value="QA">Qatar</option>
    -                    <option value="RE">Réunion</option>
    +                    <option value="RE">Réunion</option>
                         <option value="RO">Romania</option>
                         <option value="RU">Russian Federation</option>
                         <option value="RW">Rwanda</option>
    -                    <option value="BL">Saint Barthélemy</option>
    +                    <option value="BL">Saint Barthélemy</option>
                         <option value="SH">Saint Helena, Ascension and Tristan da Cunha</option>
                         <option value="KN">Saint Kitts and Nevis</option>
                         <option value="LC">Saint Lucia</option>
    @@ -413,7 +411,7 @@ function DisplayHostAPDConfig(){
     <?php 
     }
     
    -function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $status) {
    +function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $myStatus) {
       // It should not be possible to send bad data for these fields so clearly
       // someone is up to something if they fail. Fail silently.
       if (!(array_key_exists($_POST['wpa'], $wpa_array) && array_key_exists($_POST['wpa_pairwise'], $enc_types) && in_array($_POST['hw_mode'], $modes))) {
    @@ -430,21 +428,21 @@ function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $status)
       // Verify input
       if (strlen($_POST['ssid']) == 0 || strlen($_POST['ssid']) > 32) {
         // Not sure of all the restrictions of SSID
    -    $status->addMessage('SSID must be between 1 and 32 characters', 'danger');
    +    $myStatus->addMessage('SSID must be between 1 and 32 characters', 'danger');
         $good_input = false;
       }
       if (strlen($_POST['wpa_passphrase']) < 8 || strlen($_POST['wpa_passphrase']) > 63) {
    -    $status->addMessage('WPA passphrase must be between 8 and 63 characters', 'danger');
    +    $myStatus->addMessage('WPA passphrase must be between 8 and 63 characters', 'danger');
         $good_input = false;
       }
       if (! in_array($_POST['interface'], $interfaces)) {
         // The user is probably up to something here but it may also be a
         // genuine error.
    -    $status->addMessage('Unknown interface '.$_POST['interface'], 'danger');
    +    $myStatus->addMessage('Unknown interface '.$_POST['interface'], 'danger');
         $good_input = false;
       }
       if (strlen($_POST['country_code']) != 0 && strlen($_POST['country_code']) != 2) {
    -    $status->addMessage('Country code must be blank or two characters', 'danger');
    +    $myStatus->addMessage('Country code must be blank or two characters', 'danger');
         $good_input = false;
       }
     
    @@ -470,12 +468,12 @@ function SaveHostAPDConfig($wpa_array, $enc_types, $modes, $interfaces, $status)
     
           system( "sudo cp /tmp/hostapddata " . RASPI_HOSTAPD_CONFIG, $return );
           if( $return == 0 ) {
    -        $status->addMessage('Wifi Hotspot settings saved', 'success');
    +        $myStatus->addMessage('Wifi Hotspot settings saved', 'success');
           } else {
    -        $status->addMessage('Unable to save wifi hotspot settings', 'danger');
    +        $myStatus->addMessage('Unable to save wifi hotspot settings', 'danger');
           }
         } else {
    -      $status->addMessage('Unable to save wifi hotspot settings', 'danger');
    +      $myStatus->addMessage('Unable to save wifi hotspot settings', 'danger');
           return false;
         }
       }
    diff --git a/html/includes/images.php b/html/includes/images.php
    index a9490ac32..c5f359b23 100644
    --- a/html/includes/images.php
    +++ b/html/includes/images.php
    @@ -1,29 +1,39 @@
     <?php
     
    -function ListImages(){
    +function ListImages() {
    +	global $imagesSortOrder;
     
    -$images = array();
    -$chosen_day = $_GET['day'];
    -$num = 0;	// Keep track of count so we can tell user when no files exist.
    -$dir = ALLSKY_IMAGES . "/$chosen_day";
    +	$images = array();
    +	$chosen_day = $_GET['day'];
    +	$num = 0;	// Keep track of count so we can tell user when no files exist.
    +	$dir = ALLSKY_IMAGES . "/$chosen_day";
     
    -if ($handle = opendir($dir)) {
    -    while (false !== ($image = readdir($handle))) {
    -	$ext = explode(".",$image);
    -		// Allow "image-YYYYMMDDHHMMSS.jpg" and "image-resized-YYYYMMDDHHMMSS.jpg"
    -        if (preg_match('/^\w+-.*\d{14}[.](jpe?g|png)$/i', $image)){
    -            $images[] = $image;
    -	    $num += 1;
    -        }
    -    }
    -    closedir($handle);
    -}
    -
    -if ($num > 0) asort($images);
    +	if ($handle = opendir($dir)) {
    +		while (false !== ($image = readdir($handle))) {
    +			// Name format: "image-YYYYMMDDHHMMSS.jpg" or .jpe or .png
    +			if (preg_match('/^\w+-.*\d{14}[.](jpe?g|png)$/i', $image)){
    +				$images[] = $image;
    +				$num += 1;
    +			}
    +		}
    +		closedir($handle);
    +	}
     
    +	if ($num > 0) {
    +		if ($imagesSortOrder === "descending") {
    +			arsort($images);
    +			$sortOrder = "Sorted newest to oldest (descending)";
    +		} else {
    +			asort($images);
    +			$sortOrder = "Sorted oldest to newest (ascending)";
    +		}
    +		$sortOrder = "<span class='imagesSortOrder'>$sortOrder</span>";
    +	} else {
    +		$sortOrder = "";
    +	}
     ?>
     
    -<link  href="documentation/css/viewer.min.css" rel="stylesheet">
    +<link href="documentation/css/viewer.min.css" rel="stylesheet">
     <script src="js/viewer.min.js"></script>
     <script src="js/jquery-viewer.min.js"></script>
     
    @@ -44,17 +54,19 @@ function getTimeStamp(url)
     {
     	var filename = url.substring(url.lastIndexOf('/')+1);			// everything after the last "/"
     	var timeStamp = filename.substr(filename.lastIndexOf('-')+1);	// everything after the last "-"
    -	var year = timeStamp.substring(0, 4);
    -	var month = timeStamp.substring(4, 6);
    -	var day = timeStamp.substring(6, 8);
    -	var hour = timeStamp.substring(8, 10);
    -	var minute = timeStamp.substring(10, 12);
    -	var seconds = timeStamp.substring(12, 14);
    -	var date = new Date(year, month-1, day, hour, minute, seconds, 0);
    -	return date.toDateString() + " @ " + hour + ":"+minute + ":"+seconds;
    +	// YYYY MM DD HH MM SS
    +	// 0123 45 67 89 01 23
    +	var year = timeStamp.substr(0, 4);
    +	var month = timeStamp.substr(4, 2);
    +	var day = timeStamp.substr(6, 2);
    +	var hour = timeStamp.substr(8, 2);
    +	var minute = timeStamp.substr(10, 2);
    +	var second = timeStamp.substr(12, 2);
    +	var date = new Date(year, month-1, day, hour, minute, second, 0);
    +	return date.toDateString() + " @ " + hour + ":" + minute + ":" + second;
     }
     </script>
    -<h2><?php echo $chosen_day ?></h2>
    +<h2><?php echo "$chosen_day &nbsp; &nbsp; $sortOrder"; ?></h2>
     <div class='row'>
     	<div id='images'>
     <?php
    diff --git a/html/includes/liveview.php b/html/includes/liveview.php
    index 343648cb7..0c824d9a8 100644
    --- a/html/includes/liveview.php
    +++ b/html/includes/liveview.php
    @@ -1,34 +1,54 @@
     <?php
     
    -function DisplayLiveView($image_name, $delay, $daydelay, $nightdelay, $darkframe) {
    +function DisplayLiveView($image_name, $delay, $daydelay, $daydelay_postMsg, $nightdelay, $nightdelay_postMsg, $darkframe) {
    +	global $showUpdatedMessage;
    +	$myStatus = new StatusMessages();
    +
     	// Note: if liveview is left open during a day/night transition the delay will become wrong.
     	// For example, if liveview is started during the day we use "daydelay" but then
     	// at night we're still using "daydelay" but should be using "nightdelay".
     	// The user can fix this by reloading the web page.
    -	// TODO: Should we automatically reload the page every so often?
    -
    -	$status = new StatusMessages();
    +	// TODO: Should we automatically reload the page every so often (we already reload the image)?
     
    -	if ($darkframe === '1') {
    -		$status->addMessage('Currently capturing dark frames. You can turn this off on the Allsky Settings page.');
    -	} else if ($daydelay != -1) {
    +	if ($darkframe) {
    +		$myStatus->addMessage('Currently capturing dark frames. You can turn this off in the Allsky Settings page.');
    +	} else if ($showUpdatedMessage) {
     		$s =  number_format($daydelay);
    -		$msg =  "Daytime images updated every $s seconds,";
    +		$msg =  "Daytime images updated every $s seconds$daydelay_postMsg,";
     		$s =  number_format($nightdelay);
    -		$msg .= " nighttime every $s seconds";
    -		$status->addMessage("$msg", "message", true);
    +		$msg .= " nighttime every $s seconds$nightdelay_postMsg";
    +		$myStatus->addMessage("$msg", "message", true);
     	}
     ?>
     
    -<script> setTimeout(function () { getImage(); }, <?php echo $delay ?>); </script>
    +<script>
    +		function getImage() {
    +			var newImg = new Image();
    +			newImg.src = '<?php echo $image_name ?>?_ts=' + new Date().getTime();
    +			newImg.id = "current";
    +			newImg.className = "current";
    +			newImg.decode().then(() => {
    +				$("#live_container").empty().append(newImg);
    +			}).catch((err) => {
    +				if (!this.complete || typeof this.naturalWidth == "undefined" || this.naturalWidth == 0) {
    +					console.log('broken image: ', err);
    +				}
    +			}).finally(() => {
    +				// Use tail recursion to trigger the next invocation after `$delay` milliseconds
    +				setTimeout(function () { getImage(); }, <?php echo $delay ?>);
    +			});
    +		};
    +
    +		getImage();
    +</script>
     
     <div class="row">
     	<div class="panel panel-primary">
     		<div class="panel-heading"><i class="fa fa-code fa-eye"></i> Liveview</div>
     		<div class="panel-body">
    -			<?php if ($status->isMessage()) echo "<p>" . $status->showMessages() . "</p>"; ?>
    +			<?php if ($myStatus->isMessage()) echo "<p>" . $myStatus->showMessages() . "</p>"; ?>
     			<div id="live_container" class="cursorPointer live_container" title="Click to make full-screen">
    -				<img id="current" class="current" src="<?php echo $image_name ?>" style="width:100%">
    +				<img id="current" class="current" src="<?php echo $image_name ?>">
     			</div>
     		</div>
     	</div>
    diff --git a/html/includes/module.php b/html/includes/module.php
    index 9692ca806..118e20557 100644
    --- a/html/includes/module.php
    +++ b/html/includes/module.php
    @@ -4,31 +4,31 @@ function DisplayModule() {
     
     ?>
     
    -<script src="/documentation/js/all.min.js" type="application/javascript"></script>
    +<script src="/documentation/js/all.min.js?c=<?php echo ALLSKY_VERSION; ?>" type="application/javascript"></script>
     
    -<script src="/js/sortable/sortable.js"></script>
    -<script src="/js/sortable/jquery-sortable.js"></script>
    +<script src="/js/sortable/sortable.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
    +<script src="/js/sortable/jquery-sortable.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -<link rel='stylesheet' href='/css/modules.css' />
    -<script src="/js/modules/modules.js"></script>
    +<link rel='stylesheet' href='/css/modules.css?c=<?php echo ALLSKY_VERSION; ?>' />
    +<script src="/js/modules/modules.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -<script src="/js/jquery-loading-overlay/dist/loadingoverlay.min.js"></script>
    +<script src="/js/jquery-loading-overlay/dist/loadingoverlay.min.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -<script src="/js/bootbox/bootbox.all.js"></script>
    -<script src="/js/bootbox/bootbox.locales.min.js"></script>
    +<script src="/js/bootbox/bootbox.all.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
    +<script src="/js/bootbox/bootbox.locales.min.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -<link rel='stylesheet' href='/js/jquery-ui-1.13.1.custom/jquery-ui.min.css' />
    -<script src="/js/jquery-ui-1.13.1.custom/jquery-ui.min.js"></script>
    +<link rel='stylesheet' href='/js/jquery-ui-1.13.1.custom/jquery-ui.min.css?c=<?php echo ALLSKY_VERSION; ?>' />
    +<script src="/js/jquery-ui-1.13.1.custom/jquery-ui.min.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -<link rel="stylesheet" type="text/css" href="/js/overlay/imagemanager/oe-imagemanager.css" />
    -<script type="text/javascript" src="/js/overlay/imagemanager/oe-imagemanager.js"></script>
    +<link rel="stylesheet" type="text/css" href="/js/overlay/imagemanager/oe-imagemanager.css?c=<?php echo ALLSKY_VERSION; ?>" />
    +<script type="text/javascript" src="/js/overlay/imagemanager/oe-imagemanager.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -<link href="/js/dropzone/dropzone.css" type="text/css" rel="stylesheet" />
    -<script src="/js/dropzone/dropzone-min.js"></script>
    +<link href="/js/dropzone/dropzone.css?c=<?php echo ALLSKY_VERSION; ?>" type="text/css" rel="stylesheet" />
    +<script src="/js/dropzone/dropzone-min.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -<script src="/js/jquery-gpio/jquery-gpio.js"></script>
    -<script src="/js/jquery-roi/jquery-roi.js"></script>
    -<script src="/js/konva/konva.min.js"></script>
    +<script src="/js/jquery-gpio/jquery-gpio.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
    +<script src="/js/jquery-roi/jquery-roi.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
    +<script src="/js/konva/konva.min.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
     <div class="row">
         <div class="col-lg-12">
    @@ -141,7 +141,7 @@ function DisplayModule() {
                 </div>
                 <div class="modal-body">
                     <form id="oe-debug-dialog-form" class="form-horizontal">
    -                    <div class="form-group">
    +                    <!-- <div class="form-group">
                             <label for="checkbox" class="control-label col-xs-4"></label> 
                             <div class="col-xs-8">
                                 <label class="checkbox-inline">
    @@ -159,7 +159,7 @@ function DisplayModule() {
                                 </div>
                                 <p class="help-block">The maximum time a module can run for, in seconds. After this amount of time it will be disabled</p>
                             </div>
    -                    </div>
    +                    </div> -->
                         <div class="form-group">
                             <label for="checkbox" class="control-label col-xs-4"></label> 
                             <div class="col-xs-8">
    diff --git a/html/includes/moduleutil.php b/html/includes/moduleutil.php
    index 02f4fac20..5c530f614 100644
    --- a/html/includes/moduleutil.php
    +++ b/html/includes/moduleutil.php
    @@ -3,9 +3,6 @@
     include_once('functions.php');
     initialize_variables();		// sets some variables
     
    -define('RASPI_ADMIN_DETAILS', RASPI_CONFIG . '/raspap.auth');
    -
    -include_once('raspap.php');
     include_once('authenticate.php');
     
     class MODULEUTIL
    @@ -18,7 +15,7 @@ class MODULEUTIL
     
         function __construct() {
             $this->allskyModules = ALLSKY_SCRIPTS . '/modules';
    -        $this->userModules = '/opt/allsky/modules';
    +        $this->userModules = ALLSKY_MODULE_LOCATION . '/modules';
         }
     
         public function run()
    @@ -77,7 +74,7 @@ private function runRequest() {
         private function readModuleData($moduleDirectory, $type, $event) {
             $arrFiles = array();
             $handle = opendir($moduleDirectory);
    - 
    +
             if ($handle) {
                 while (($entry = readdir($handle)) !== FALSE) {
                     if (preg_match('/^allsky_/', $entry)) {
    @@ -90,13 +87,13 @@ private function readModuleData($moduleDirectory, $type, $event) {
                             foreach ($fileContents as $sourceLine) {
                                 $line = str_replace(" ", "", $sourceLine);
                                 $line = str_replace("\n", "", $line);
    -                            $line = str_replace("\r", "", $line);                            
    +                            $line = str_replace("\r", "", $line);
                                 $line = strtolower($line);
                                 if ($line == "metadata={") {
                                     $found = true;
                                     $sourceLine = str_ireplace("metadata","", $sourceLine);
                                     $sourceLine = str_ireplace("=","", $sourceLine);
    -                                $sourceLine = str_ireplace(" ","", $sourceLine);                                
    +                                $sourceLine = str_ireplace(" ","", $sourceLine);
                                 }
     
                                 if ($found) {
    @@ -147,7 +144,7 @@ private function endsWith($string, $endString) {
         }
     
         private function changeOwner($filename) {
    -        $user = get_current_user();        
    +        $user = get_current_user();
             exec("sudo chown " . $user . " " . $filename);
         }
     
    @@ -160,16 +157,16 @@ public function getModulesSettings() {
     
         public function getRestore() {
             $flow = $_GET['flow'];
    -                
    +
             $configFileName = ALLSKY_MODULES . '/' . 'postprocessing_' . strtolower($flow) . '.json';
             $backupConfigFileName = $configFileName . '-last';
             copy($backupConfigFileName, $configFileName);
             $this->changeOwner($configFileName);
    -        $this->sendResponse();        
    +        $this->sendResponse();
         }
     
         public function postModulesSettings() {
    -        $configFileName = ALLSKY_MODULES . '/module-settings.json';        
    +        $configFileName = ALLSKY_MODULES . '/module-settings.json';
             $settings = $_POST['settings'];
             $formattedJSON = json_encode(json_decode($settings), JSON_PRETTY_PRINT);
     
    @@ -182,16 +179,15 @@ public function postModulesSettings() {
         }
     
         public function getModuleBaseData() {
    -        $cam_type = getCameraType();
    -        $settings_file = getSettingsFile($cam_type);
    -        $camera_settings_str = file_get_contents($settings_file, true);
    -        $camera_settings_array = json_decode($camera_settings_str, true);
    -        $angle = $camera_settings_array['angle'];
    -        $lat = $camera_settings_array['latitude'];
    -        $lon = $camera_settings_array['longitude'];
    +        global $settings_array;		// defined in initialize_variables()
    +        $angle = $settings_array['angle'];
    +        $lat = $settings_array['latitude'];
    +        $lon = $settings_array['longitude'];
     
             $result['lat'] = $lat;
             $result['lon'] = $lon;
    +        $imageDir = get_variable(ALLSKY_HOME . '/variables.sh', "IMG_DIR=", 'current/tmp');
    +        $result['filename'] = $imageDir . '/' . $settings_array['filename'];
     
             exec("sunwait poll exit set angle $angle $lat $lon", $return, $retval);
             if ($retval == 2) {
    @@ -203,7 +199,7 @@ public function getModuleBaseData() {
             }
     
             $result['version'] = ALLSKY_VERSION;
    -        
    +
             $configFileName = ALLSKY_MODULES . '/module-settings.json';
             $rawConfigData = file_get_contents($configFileName);
             $configData = json_decode($rawConfigData);
    @@ -215,8 +211,8 @@ public function getModuleBaseData() {
     
         public function getModules() {
             $result = $this->readModules();
    -        $result = json_encode($result, JSON_FORCE_OBJECT);     
    -        $this->sendResponse($result);       
    +        $result = json_encode($result, JSON_FORCE_OBJECT);
    +        $this->sendResponse($result);
         }
     
         private function readModules() {
    @@ -226,6 +222,7 @@ private function readModules() {
     
             $event = $_GET['event'];
             $configFileName = ALLSKY_MODULES . '/' . 'postprocessing_' . strtolower($event) . '.json';
    +        $debugFileName = ALLSKY_MODULES . '/' . 'postprocessing_' . strtolower($event) . '-debug.json';
             $rawConfigData = file_get_contents($configFileName);
             $configData = json_decode($rawConfigData);
     
    @@ -243,7 +240,7 @@ private function readModules() {
             foreach ($allModules as $moduleData) {
                 $module = str_replace('allsky_', '', $moduleData["module"]);
                 $module = str_replace('.py', '', $module);
    -            
    +
                 if (!isset($configData->{$module})) {
                     $moduleData["enabled"] = false;
                     $availableResult[$module] = $moduleData;
    @@ -282,12 +279,12 @@ private function readModules() {
                 if (isset($data->lastexecutiontime)) {
                     $moduleData['lastexecutiontime'] = $data->lastexecutiontime;
                 } else {
    -                $moduleData['lastexecutiontime'] = '0';                
    +                $moduleData['lastexecutiontime'] = '0';
                 }
                 if (isset($data->lastexecutionresult)) {
                     $moduleData['lastexecutionresult'] = $data->lastexecutionresult;
                 } else {
    -                $moduleData['lastexecutionresult'] = '';                
    +                $moduleData['lastexecutionresult'] = '';
                 }
     
                 $selectedResult[$selectedName] = $moduleData;
    @@ -297,11 +294,19 @@ private function readModules() {
             if (file_exists($configFileName . '-last')) {
                 $restore = true;
             }
    +
    +        $debugInfo = null;
    +        if (file_exists($debugFileName)) {
    +            $debugInfo = file_get_contents($debugFileName);
    +            $debugInfo = json_decode($debugInfo);
    +        }
    +
             $result = [
                 'available' => $availableResult,
                 'selected'=> $selectedResult,
                 'corrupted' => $corrupted,
    -            'restore' => $restore
    +            'restore' => $restore,
    +            'debug' => $debugInfo
             ];
     
             return $result;
    @@ -335,7 +340,7 @@ private function CheckForDisabledModules($newModules, $oldModules) {
     
             foreach ($oldModules as $key=>$module) {
                 $moduleList[$key] = $module->module;
    -        } 
    +        }
     
             foreach ($newModules as $key=>$module) {
                 if (isset($moduleList[$key])) {
    @@ -382,7 +387,7 @@ public function deleteModules() {
     
         public function getReset() {
             $flow = $_GET['flow'];
    -        
    +
             $sourceConfigFileName = ALLSKY_REPO . '/modules/postprocessing_' . strtolower($flow) . '.json';
             $rawConfigData = file_get_contents($sourceConfigFileName);
             $configFileName = ALLSKY_MODULES . '/' . 'postprocessing_' . strtolower($flow) . '.json';
    diff --git a/html/includes/overlay.php b/html/includes/overlay.php
    index b0dc1faec..cc550665e 100644
    --- a/html/includes/overlay.php
    +++ b/html/includes/overlay.php
    @@ -1,447 +1,507 @@
     <?php
    -
     function DisplayOverlay($image_name)
     {
    +	global $settings_array;
     	$displayMaskTab = false;		// Should the "Mask" tab appear?
    -
    +	$numTabs = 1 + ($displayMaskTab ? 1 : 0);
    +	$myStatus = new StatusMessages();
    +
    +	// TODO: can remove in next major release when Overlay Method is deleted
    +	if (getVariableOrDefault($settings_array, 'overlaymethod', 0) === 0) {
    +		$msg = "<br>The <span class='WebUISetting'>Overlay Method</span>";
    +		$msg .= " on the <span class='WebUILink'>Allsky Settings</span> page is set to";
    +		$msg .= " <span class='WebUIValue'>legacy</span>";
    +		$msg .=" so the overlay below will NOT be used.";
    +		$msg .= " To change that, change the setting to";
    +		$msg .= " <span class='WebUIValue'>module</span>.";
    +		$msg .= "<br>Also, the &nbsp;";
    +		$msg .= " <i class='fa-regular fa-square-check navbar-default btn btn-lg navbar-btn'";
    +		$msg .=	" style='color: black; padding: 0 !important; margin: 0; border: 0;'></i>";
    +		$msg .= "  &nbsp; icon below will not work until you change the setting.<br><br>";
    +		$myStatus->addMessage($msg, 'danger');
    +	}
     ?>
     
    -    <script src="/js/jquery-loading-overlay/dist/loadingoverlay.min.js"></script>
    +    <script src="/js/jquery-loading-overlay/dist/loadingoverlay.min.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -    <script src="/js/moment/moment-min.js"></script>
    +    <script src="/js/moment/moment-min.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -    <script src="/js/overlay/oe-overlayeditor.js"></script>
    -    <script src="/js/overlay/oe-config.js"></script>
    -    <script src="/js/overlay/oe-uimanager.js"></script>
    +    <script src="/js/overlay/oe-overlayeditor.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
    +    <script src="/js/overlay/oe-config.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
    +    <script src="/js/overlay/oe-uimanager.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -    <script src="/js/overlay/fields/oe-fieldmanager.js"></script>
    -    <script src="/js/overlay/fields/oe-field.js"></script>
    -    <script src="/js/overlay/fields/oe-text.js"></script>
    -    <script src="/js/overlay/fields/oe-image.js"></script>
    -    <script src="/js/overlay/oe-exposure.js"></script>
    +    <script src="/js/overlay/fields/oe-fieldmanager.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
    +    <script src="/js/overlay/fields/oe-field.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
    +    <script src="/js/overlay/fields/oe-text.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
    +    <script src="/js/overlay/fields/oe-image.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
    +    <script src="/js/overlay/oe-exposure.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -    <script src="/js/bootbox/bootbox.all.js"></script>
    -    <script src="/js/bootbox/bootbox.locales.min.js"></script>
    +    <script src="/js/bootbox/bootbox.all.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
    +    <script src="/js/bootbox/bootbox.locales.min.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -    <link rel='stylesheet' href='/js/jquery-ui-1.13.1.custom/jquery-ui.min.css' />
    -    <script src="/js/jquery-ui-1.13.1.custom/jquery-ui.min.js"></script>
    +    <link rel='stylesheet' href='/js/jquery-ui-1.13.1.custom/jquery-ui.min.css?c=<?php echo ALLSKY_VERSION; ?>' />
    +    <script src="/js/jquery-ui-1.13.1.custom/jquery-ui.min.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -    <link rel='stylesheet' href='/js/spectrum/dist/spectrum.css' />
    -    <script src="/js/spectrum/dist/spectrum.js"></script>
    +    <link rel='stylesheet' href='/js/spectrum/dist/spectrum.css?c=<?php echo ALLSKY_VERSION; ?>' />
    +    <script src="/js/spectrum/dist/spectrum.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -    <link rel='stylesheet' href='/js/jqPropertyGrid/jqPropertyGrid.css' />
    -    <script src="/js/jqPropertyGrid/jqPropertyGrid.js"></script>
    +    <link rel='stylesheet' href='/js/jqPropertyGrid/jqPropertyGrid.css?c=<?php echo ALLSKY_VERSION; ?>' />
    +    <script src="/js/jqPropertyGrid/jqPropertyGrid.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -    <link rel="stylesheet" type="text/css" href="/js/datatables/datatables.min.css" />
    -    <script type="text/javascript" src="/js/datatables/datatables.js"></script>
    +    <link rel="stylesheet" type="text/css" href="/js/datatables/datatables.min.css?c=<?php echo ALLSKY_VERSION; ?>" />
    +    <script type="text/javascript" src="/js/datatables/datatables.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -    <link rel="stylesheet" type="text/css" href="/js/overlay/imagemanager/oe-imagemanager.css" />
    -    <script type="text/javascript" src="/js/overlay/imagemanager/oe-imagemanager.js"></script>
    +    <link rel="stylesheet" type="text/css" href="/js/overlay/imagemanager/oe-imagemanager.css?c=<?php echo ALLSKY_VERSION; ?>" />
    +    <script type="text/javascript" src="/js/overlay/imagemanager/oe-imagemanager.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    +    <script type="text/javascript" src="/js/jquery-overlaymanager/jquery-overlaymanager.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -    <link href="/js/dropzone/dropzone.css" type="text/css" rel="stylesheet" />
    -    <script src="/js/dropzone/dropzone-min.js"></script>
    +    <link href="/js/dropzone/dropzone.css?c=<?php echo ALLSKY_VERSION; ?>" type="text/css" rel="stylesheet" />
    +    <script src="/js/dropzone/dropzone-min.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -    <script src="/js/konva/konva.min.js"></script>
    +    <script src="/js/konva/konva.min.js?c=<?php echo ALLSKY_VERSION; ?>"></script>
     
    -    <link href="/css/overlay.css" rel="stylesheet">
    +    <link href="/css/overlay.css?c=<?php echo ALLSKY_VERSION; ?>" rel="stylesheet">
     
         <div id="oeeditor">
    +        <div id="oe-overlay-manager"></div>     
             <div class="row">
    -            <div id="oe-viewport" class="panel panel-primary">
    -                <div class="panel-heading"><i class="fa fa-code fa-edit"></i> Overlay Editor</div>
    -
    -
    -                <div>
    -
    -                    <ul class="nav nav-tabs" role="tablist">
    -                        <li role="presentation" class="active">
    -							<a href="#oe-editor-tab" aria-controls="oe-editor-tab" role="tab" data-toggle="tab" id="oe-overlay-editor-tab">Overlay Editor</a></li>
    +        <div id="oe-viewport" class="panel panel-primary">
    +            <div id="oe-overlay-not-running" class="oe-not-running big hidden">
    +                <div class="center-full">
    +                    <div class="center-paragraph">
    +                        <h1>Allsky is not currently capturing images</h1>
    +                        <p>Please wait for Allsky to begin capturing before using the Overlay Editor</p>
    +                        <small>You can stay on this page and the Overlay Editor will start automatically once Allsky is running. <span id="oe-overlay-not-running-status"></span></small>
    +                    </div>
    +                </div>
    +            </div> 
    +            <div class="panel-heading"><i class="fa fa-code fa-edit"></i> Overlay Editor</div>
    +                <p id="editor-messages"><?php $myStatus->showMessages(); ?></p>
    +            <div>
    +<?php if ($numTabs > 1) { // don't show just a single tab ?>
    +                <ul class="nav nav-tabs" role="tablist">
    +                    <li role="presentation" class="active">
    +                            <a href="#oe-editor-tab" aria-controls="oe-editor-tab" role="tab" data-toggle="tab" id="oe-overlay-editor-tab">Overlay Editor</a></li>
     <?php if ($displayMaskTab) { ?>
    -                        <li role="presentation"><a href="#oe-exposure-tab" aria-controls="oe-exposure-tab" role="tab" data-toggle="tab">Auto Exposure Mask</a></li>
    +                    <li role="presentation">
    +                            <a href="#oe-exposure-tab" aria-controls="oe-exposure-tab" role="tab" data-toggle="tab">Auto Exposure Mask</a></li>
     <?php } ?>
    -                    </ul>
    -
    -                    <div class="tab-content">
    -                        <div role="tabpanel" class="tab-pane active" id="oe-editor-tab">
    -                            <nav class="navbar navbar-default">
    -                                <div class="container-fluid">
    -                                    <div class="navbar-header">
    -                                        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#oe-main-navbar" aria-expanded="false">
    -                                            <span class="sr-only">Toggle navigation</span>
    -                                            <span class="icon-bar"></span>
    -                                            <span class="icon-bar"></span>
    -                                            <span class="icon-bar"></span>                                            
    -                                        </button>
    -                                    </div>
    -    
    -                                    <div class="collapse navbar-collapse" id="oe-main-navbar">
    -                                        <ul class="nav navbar-nav">
    -                                            <li>
    -                                                <div class="tooltip-wrapper disabled" data-toggle="tooltip" data-container="body" data-placement="top" title="Save The Current Configuration">
    -                                                    <div class="btn btn-lg navbar-btn disabled" id="oe-save"><i class="fa-solid fa-floppy-disk"></i></div>
    -                                                </div>
    -                                            </li>
    -                                            <li>
    -                                                <div class="btn btn-lg navbar-btn" id="oe-add-text" data-toggle="tooltip" data-container="body" data-placement="top" title="Add New Text Field"><i class="fa-solid fa-font"></i></div>
    -                                            </li>
    -                                            <li>
    -                                                <div class="btn btn-lg navbar-btn" id="oe-add-image" data-toggle="tooltip" data-container="body" data-placement="top" title="Add Existing Image Field"><i class="fa-regular fa-image"></i></div>
    -                                            </li>
    -                                            <li>
    -                                                <div class="tooltip-wrapper disabled" data-toggle="tooltip" data-container="body" data-placement="top" title="Delete The Selected Field">
    -                                                    <div class="btn btn-lg navbar-btn disabled" id="oe-delete"><i class="fa-solid fa-xmark"></i></div>
    -                                                </div>
    -                                            </li>
    -                                            <li>
    -                                                <div class="btn btn-lg navbar-btn" id="oe-item-list" data-toggle="tooltip" data-container="body" data-placement="top" title="Variable Manager"><i class="fa-regular fa-rectangle-list"></i></div>
    -                                            </li>
    -                                            <li>
    -                                                <div class="btn btn-lg navbar-btn" id="oe-test-mode" data-toggle="tooltip" data-container="body" data-placement="top" title="Display Sample Data"><i class="fa-regular fa-square-check"></i></div>
    -                                            </li>
    -
    -                                            <li>
    -                                                <div class="btn btn-lg navbar-btn oe-zoom" id="oe-zoom-in" data-toggle="tooltip" data-container="body" data-placement="top" title="Zoom In"><i class="fa-solid fa-magnifying-glass-plus"></i></div>
    -                                            </li>
    -                                            <li>
    -                                                <div class="btn btn-lg navbar-btn oe-zoom" id="oe-zoom-out" data-toggle="tooltip" data-container="body" data-placement="top" title="Zoom Out"><i class="fa-solid fa-magnifying-glass-minus"></i></div>
    -                                            </li>
    -                                            <li>
    -                                                <div class="btn btn-lg navbar-btn oe-zoom" id="oe-zoom-full" data-toggle="tooltip" data-container="body" data-placement="top" title="View Full Size"><i class="fa-solid fa-up-right-and-down-left-from-center"></i></div>
    -                                            </li>
    -                                            <li>
    -                                                <div class="btn btn-lg navbar-btn oe-zoom" id="oe-zoom-fit" data-toggle="tooltip" data-container="body" data-placement="top" title="Fit to Window"><i class="fa-solid fa-down-left-and-up-right-to-center"></i></div>
    -                                            </li>
    -                                        </ul>
    -                                        <ul class="nav navbar-nav navbar-right">
    -                                            <li id="oe-toolbar-debug" class="hidden">
    -                                                <div id="oe-toobar-debug-button" class="btn btn-lg navbar-btn" data-toggle="tooltip" data-container="body" data-placement="top" title="Debug Info"><i class="fa-solid fa-bug"></i></div>
    -                                            </li>
    -                                            <li>
    -                                                <div id="oe-upload-font" class="btn btn-lg navbar-btn" data-toggle="tooltip" data-container="body" data-placement="top" title="Font Manager">
    -                                                    <i class="fa-solid fa-download"></i>
    -                                                </div>
    -                                            </li>
    -                                            <li>
    -                                                <div id="oe-show-image-manager" class="btn btn-lg navbar-btn" data-toggle="tooltip" data-container="body" data-placement="top" title="Image Manager"><i class="fa-regular fa-images"></i></div>
    -                                            </li>
    -                                            <li>
    -                                                <div class="btn btn-lg navbar-btn" id="oe-options" data-toggle="tooltip" data-container="body" data-placement="top" title="Layout and App Options"><i class="fa-solid fa-gear"></i>
    -                                                </div>
    -                                            </li>
    -                                        </ul>
    -                                    </div>
    +                </ul>
    +<?php } ?>
    +                <div class="tab-content">
    +                    <div role="tabpanel" class="tab-pane active" id="oe-editor-tab">
    +                        <nav class="navbar navbar-default">
    +                            <div class="container-fluid">
    +                                <div class="navbar-header">
    +                                    <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#oe-main-navbar" aria-expanded="false">
    +                                        <span class="sr-only">Toggle navigation</span>
    +                                        <span class="icon-bar"></span>
    +                                        <span class="icon-bar"></span>
    +                                        <span class="icon-bar"></span>
    +                                    </button>
                                     </div>
    -                            </nav>
    -                            <div class="oe-editor panel-body">
    -                                <div id="overlay_container" style="background-color: black; position: relative">
    -                                    <div id="oe-editor-stage"></div>
    +
    +                                <div class="collapse navbar-collapse" id="oe-main-navbar">
    +                                    <ul class="nav navbar-nav" id="oe-editor-toolbar">
    +                                        <li>
    +                                            <div class="tooltip-wrapper disabled" data-toggle="tooltip" data-container="body" data-placement="top" title="Save The Current Configuration">
    +                                                <div class="btn btn-lg navbar-btn oe-button disabled" id="oe-save"><i class="fa-solid fa-floppy-disk"></i></div>
    +                                            </div>
    +                                        </li>
    +                                        <li>
    +                                            <div class="btn btn-lg navbar-btn oe-button" id="oe-add-text" data-toggle="tooltip" data-container="body" data-placement="top" title="Add New Text Field"><i class="fa-solid fa-font"></i></div>
    +                                        </li>
    +                                        <li>
    +                                            <div class="btn btn-lg navbar-btn oe-button" id="oe-add-image" data-toggle="tooltip" data-container="body" data-placement="top" title="Add Existing Image Field"><i class="fa-regular fa-image"></i></div>
    +                                        </li>
    +                                        <li>
    +                                            <div class="tooltip-wrapper disabled" data-toggle="tooltip" data-container="body" data-placement="top" title="Delete The Selected Field">
    +                                                <div class="btn btn-lg navbar-btn oe-button disabled" id="oe-delete"><i class="fa-solid fa-xmark"></i></div>
    +                                            </div>
    +                                        </li>
    +                                        <li>
    +                                            <div class="btn btn-lg navbar-btn oe-button" id="oe-item-list" data-toggle="tooltip" data-container="body" data-placement="top" title="Variable Manager"><i class="fa-regular fa-rectangle-list"></i></div>
    +                                        </li>
    +                                        <li>
    +                                            <div class="btn btn-lg navbar-btn oe-button" id="oe-test-mode" data-toggle="tooltip" data-container="body" data-placement="top" title="Display Sample Data"><i class="fa-regular fa-square-check"></i></div>
    +                                        </li>
    +
    +                                        <li>
    +                                            <div class="btn btn-lg navbar-btn oe-zoom oe-button border-left" id="oe-zoom-in" data-toggle="tooltip" data-container="body" data-placement="top" title="Zoom In"><i class="fa-solid fa-magnifying-glass-plus"></i></div>
    +                                        </li>
    +                                        <li>
    +                                            <div class="btn btn-lg navbar-btn oe-zoom oe-button" id="oe-zoom-out" data-toggle="tooltip" data-container="body" data-placement="top" title="Zoom Out"><i class="fa-solid fa-magnifying-glass-minus"></i></div>
    +                                        </li>
    +                                        <li>
    +                                            <div class="btn btn-lg navbar-btn oe-zoom oe-button" id="oe-zoom-full" data-toggle="tooltip" data-container="body" data-placement="top" title="View Full Size"><i class="fa-solid fa-up-right-and-down-left-from-center"></i></div>
    +                                        </li>
    +                                        <li>
    +                                            <div class="btn btn-lg navbar-btn oe-zoom oe-button" id="oe-zoom-fit" data-toggle="tooltip" data-container="body" data-placement="top" title="Fit to Window"><i class="fa-solid fa-down-left-and-up-right-to-center"></i></div>
    +                                        </li>
    +                                        <li>
    +                                            <div class="btn btn-lg navbar-btn border-left" id="oe-show-overlay-manager" data-toggle="tooltip" data-container="body" data-placement="top" title="Overlay Manager"><i class="fa-solid fa-gears"></i></div>
    +                                        </li>
    +                                        <li>
    +                                            <div class="btn btn-lg navbar-btn oe-field-errors hidden" id="oe-field-errors" data-toggle="tooltip" data-container="body" data-placement="top" title="Display Field Errors"><i class="fa-solid fa-circle-exclamation"></i></div>
    +                                        </li>
    +                                    </ul>
    +                                    <ul class="nav navbar-nav navbar-right">
    +                                        <li id="oe-toolbar-debug" class="hidden">
    +                                            <div id="oe-toobar-debug-button" class="btn btn-lg navbar-btn oe-button" data-toggle="tooltip" data-container="body" data-placement="top" title="Debug Info"><i class="fa-solid fa-bug"></i></div>
    +                                        </li>
    +                                        <li>
    +                                            <div id="oe-upload-font" class="btn btn-lg navbar-btn oe-button" data-toggle="tooltip" data-container="body" data-placement="top" title="Font Manager">
    +                                                <i class="fa-solid fa-download"></i>
    +                                            </div>
    +                                        </li>
    +                                        <li>
    +                                            <div id="oe-show-image-manager" class="btn btn-lg navbar-btn oe-button" data-toggle="tooltip" data-container="body" data-placement="top" title="Image Manager"><i class="fa-regular fa-images"></i></div>
    +                                        </li>
    +                                        <li>
    +                                            <div class="btn btn-lg navbar-btn oe-button" id="oe-options" data-toggle="tooltip" data-container="body" data-placement="top" title="Layout and App Options"><i class="fa-solid fa-gear"></i>
    +                                            </div>
    +                                        </li>
    +                                    </ul>
                                     </div>
                                 </div>
    +                        </nav>                        
    +                        <div class="oe-editor panel-body">
    +                            <div id="overlay_container" style="background-color: black; position: relative">
    +                                <div id="oe-overlay-disable" class="hidden">
    +                                    <div class="center">
    +                                        <div class="center-paragraph"><h2>You are using a default <?php echo(getTOD()); ?> time overlay.</h2> <p>To create a new overlay click <a href="#" id="oe-overlay-disable-new">here</a></p></div>
    +                                    </div>
    +                                </div> 
    +                                <div id="oe-editor-stage"></div>
    +                            </div>
                             </div>
    +                    </div>
     <?php if ($displayMaskTab) { ?>
    -                        <div role="tabpanel" class="tab-pane" id="oe-exposure-tab">
    -                            <nav class="navbar navbar-default">
    -                                <div class="container-fluid">
    -
    -                                    <div class="navbar-header">
    -                                        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#oe-autoexposure-navbar" aria-expanded="false">
    -                                            <span class="sr-only">Toggle navigation</span>
    -                                            <span class="icon-bar"></span>
    -                                            <span class="icon-bar"></span>
    -                                            <span class="icon-bar"></span>                                            
    -                                        </button>
    -                                    </div>
    -
    -                                    <div class="collapse navbar-collapse" id="oe-autoexposure-navbar">
    -                                        <ul class="nav navbar-nav">
    -                                            <li>
    -                                                <div class="btn btn-lg navbar-btn glyphicon" id="oe-autoexposure-save" data-toggle="tooltip" data-placement="top" data-container="body" title="Save The AutoExposure Mask"><i class="fa-solid fa-floppy-disk"></i></div>
    -                                            </li>
    -                                            <li>
    -                                                <div class="btn btn-lg navbar-btn" id="oe-autoexposure-reset" data-toggle="tooltip" data-placement="top" data-container="body" title="Reset The AutoExposure Mask"><i class="fa-solid fa-rotate-right"></i></div>
    -                                            </li>
    -
    -
    -
    -                                            <li>
    -                                                <div class="btn btn-lg navbar-btn oe-autoexposure-zoom" id="oe-autoexposure-zoom-in" data-toggle="tooltip" data-container="body" data-placement="top" title="Zoom In"><i class="fa-solid fa-magnifying-glass-plus"></i></div>
    -                                            </li>
    -                                            <li>
    -                                                <div class="btn btn-lg navbar-btn oe-autoexposure-zoom" id="oe-autoexposure-zoom-out" data-toggle="tooltip" data-container="body" data-placement="top" title="Zoom Out"><i class="fa-solid fa-magnifying-glass-minus"></i></div>
    -                                            </li>
    -                                            <li>
    -                                                <div class="btn btn-lg navbar-btn oe-autoexposure-zoom" id="oe-autoexposure-zoom-full" data-toggle="tooltip" data-container="body" data-placement="top" title="View Full Size"><i class="fa-solid fa-up-right-and-down-left-from-center"></i></div>
    -                                            </li>
    -                                            <li>
    -                                                <div class="btn btn-lg navbar-btn oe-autoexposure-zoom" id="oe-autoexposure-zoom-fit" data-toggle="tooltip" data-container="body" data-placement="top" title="Fit to Window"><i class="fa-solid fa-down-left-and-up-right-to-center"></i></div>
    -                                            </li>
    -
    -
    -
    -                                        </ul>
    -                                    </div>
    +                    <div role="tabpanel" class="tab-pane" id="oe-exposure-tab">
    +                        <nav class="navbar navbar-default">
    +                            <div class="container-fluid">
    +
    +                                <div class="navbar-header">
    +                                    <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#oe-autoexposure-navbar" aria-expanded="false">
    +                                        <span class="sr-only">Toggle navigation</span>
    +                                        <span class="icon-bar"></span>
    +                                        <span class="icon-bar"></span>
    +                                        <span class="icon-bar"></span>
    +                                    </button>
                                     </div>
    -                            </nav>
    -                            <div class="oe-maskeditor panel-body">
    -                                <div id="mask_container" style="background-color: black; margin-bottom: 15px; position: relative">
    -                                    <div id="oe-exposure-stage"></div>
    +
    +                                <div class="collapse navbar-collapse" id="oe-autoexposure-navbar">
    +                                    <ul class="nav navbar-nav">
    +                                        <li>
    +                                            <div class="btn btn-lg navbar-btn glyphicon" id="oe-autoexposure-save" data-toggle="tooltip" data-placement="top" data-container="body" title="Save The AutoExposure Mask"><i class="fa-solid fa-floppy-disk"></i></div>
    +                                        </li>
    +                                        <li>
    +                                            <div class="btn btn-lg navbar-btn" id="oe-autoexposure-reset" data-toggle="tooltip" data-placement="top" data-container="body" title="Reset The AutoExposure Mask"><i class="fa-solid fa-rotate-right"></i></div>
    +                                        </li>
    +                                        <li>
    +                                            <div class="btn btn-lg navbar-btn oe-autoexposure-zoom" id="oe-autoexposure-zoom-in" data-toggle="tooltip" data-container="body" data-placement="top" title="Zoom In"><i class="fa-solid fa-magnifying-glass-plus"></i></div>
    +                                        </li>
    +                                        <li>
    +                                            <div class="btn btn-lg navbar-btn oe-autoexposure-zoom" id="oe-autoexposure-zoom-out" data-toggle="tooltip" data-container="body" data-placement="top" title="Zoom Out"><i class="fa-solid fa-magnifying-glass-minus"></i></div>
    +                                        </li>
    +                                        <li>
    +                                            <div class="btn btn-lg navbar-btn oe-autoexposure-zoom" id="oe-autoexposure-zoom-full" data-toggle="tooltip" data-container="body" data-placement="top" title="View Full Size"><i class="fa-solid fa-up-right-and-down-left-from-center"></i></div>
    +                                        </li>
    +                                        <li>
    +                                            <div class="btn btn-lg navbar-btn oe-autoexposure-zoom" id="oe-autoexposure-zoom-fit" data-toggle="tooltip" data-container="body" data-placement="top" title="Fit to Window"><i class="fa-solid fa-down-left-and-up-right-to-center"></i></div>
    +                                        </li>
    +
    +
    +
    +                                    </ul>
                                     </div>
                                 </div>
    +                        </nav>
    +                        <div class="oe-maskeditor panel-body">
    +                            <div id="mask_container" style="background-color: black; margin-bottom: 15px; position: relative">
    +                                <div id="oe-exposure-stage"></div>
    +                            </div>
                             </div>
    -<?php } ?>
                         </div>
    +<?php } ?>
                     </div>
                 </div>
    +        </div>
     
    -            <div id="textdialog" title="Text Properties">
    -                <div id="textpropgrid"></div>
    -            </div>
    +        <div id="textdialog" title="Text Properties">
    +            <div id="textpropgrid"></div>
    +        </div>
     
    -            <div id="imagedialog" title="Image Properties">
    -                <div id="imagepropgrid"></div>
    -            </div>
    +        <div id="imagedialog" title="Image Properties">
    +            <div id="imagepropgrid"></div>
    +        </div>
     
    -            <div id="debugdialog" title="Debug Info">
    -                <div id="debugpropgrid"></div>
    -            </div>
    +        <div id="debugdialog" title="Debug Info">
    +            <div id="debugpropgrid"></div>
    +        </div>
     
    -            <div id="formatdialog" title="Format Help">
    -                <table id="formatlisttable" class="hidden" style="width:100%">
    -                    <thead>
    -                        <tr>
    -                            <th>Format</th>
    -                            <th>Description</th>
    -                            <th>Sample</th>
    -                            <th>Type</th>
    -                            <th></th>
    -                        </tr>
    -                    </thead>
    -                </table>                
    -            </div>
    +        <div id="formatdialog" title="Format Help">
    +            <table id="formatlisttable" class="hidden" style="width:100%">
    +                <thead>
    +                    <tr>
    +                        <th>Format</th>
    +                        <th>Description</th>
    +                        <th>Sample</th>
    +                        <th>Type</th>
    +                        <th></th>
    +                    </tr>
    +                </thead>
    +            </table>
    +        </div>
     
    -            <div class="modal" role="dialog" id="oe-item-list-dialog">
    -                <div class="modal-dialog modal-lg" role="document">
    -                    <div class="modal-content">
    -                        <div class="modal-header">
    -                            <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
    -                            <h4 class="modal-title">Variable Manager</h4>
    +        <div class="modal" role="dialog" id="oe-field-errors-dialog">
    +            <div class="modal-dialog modal-lg" role="document">
    +                <div class="modal-content">
    +                    <div class="modal-header">
    +                        <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
    +                        <h4 class="modal-title">Field Errors</h4>
    +                    </div>
    +                    <div class="modal-body">
    +                        <div role="tabpanel" class="tab-pane active" id="oe-field-errors-dialog-fields">
    +                            <table id="fielderrorstable" class="display compact" style="width:98%">
    +                                <thead>
    +                                    <tr>
    +                                        <th>Field Name</th>
    +                                        <th>Type</th>
    +                                        <th>&nbsp;</th>
    +                                    </tr>
    +                                </thead>
    +                            </table>
                             </div>
    -                        <div class="modal-body">
    -                            <ul class="nav nav-tabs" role="tablist">
    -                                <li role="presentation" class="active"><a href="#oe-item-list-dialog-allsky" role="tab" data-toggle="tab">AllSky Variables</a></li>
    -                                <li role="presentation"><a href="#oe-item-list-dialog-all" aria-controls="profile" role="tab" data-toggle="tab">All Variables</a></li>
    -                            </ul>
    +                    </div>
    +                    <div class="modal-footer">
    +                        <button type="button" class="btn btn-default" id="oe-field-errors-dialog-close">Close</button>
    +                    </div>
    +                </div>
    +            </div>
    +        </div>
     
    -                            <div class="tab-content">
    -                                <div role="tabpanel" class="tab-pane active" id="oe-item-list-dialog-allsky">
    -                                    <table id="itemlisttable" class="display compact" style="width:98%">
    +        <div class="modal" role="dialog" id="oe-item-list-dialog">
    +            <div class="modal-dialog modal-lg" role="document">
    +                <div class="modal-content">
    +                    <div class="modal-header">
    +                        <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
    +                        <h4 class="modal-title">Variable Manager</h4>
    +                    </div>
    +                    <div class="modal-body">
    +                        <ul class="nav nav-tabs" role="tablist">
    +                            <li role="presentation" class="active"><a href="#oe-item-list-dialog-allsky" role="tab" data-toggle="tab">AllSky Variables</a></li>
    +                            <li role="presentation"><a href="#oe-item-list-dialog-all" aria-controls="profile" role="tab" data-toggle="tab">All Variables</a></li>
    +                        </ul>
    +
    +                        <div class="tab-content">
    +                            <div role="tabpanel" class="tab-pane active" id="oe-item-list-dialog-allsky">
    +                                <table id="itemlisttable" class="display compact" style="width:98%">
    +                                    <thead>
    +                                        <tr>
    +                                            <th>id</th>
    +                                            <th>Variable Name</th>
    +                                            <th>Description</th>
    +                                            <th>Format</th>
    +                                            <th>Type</th>
    +                                            <th>&nbsp;</th>
    +                                        </tr>
    +                                    </thead>
    +                                </table>
    +                            </div>
    +                            <div role="tabpanel" class="tab-pane" id="oe-item-list-dialog-all">
    +                                <div id="oe-item-list-dialog-all-table">
    +                                    <table id="allitemlisttable" class="display compact" style="width:98%">
                                             <thead>
                                                 <tr>
                                                     <th>id</th>
    -                                                <th>Variable Name</th>
    -                                                <th>Description</th>
    -                                                <th>Format</th>
    -                                                <th>Type</th>
    +                                                <th>Name</th>
    +                                                <th>Value</th>
                                                     <th>&nbsp;</th>
                                                 </tr>
                                             </thead>
                                         </table>
                                     </div>
    -                                <div role="tabpanel" class="tab-pane" id="oe-item-list-dialog-all">
    -                                    <div id="oe-item-list-dialog-all-table">
    -                                        <table id="allitemlisttable" class="display compact" style="width:98%">
    -                                            <thead>
    -                                                <tr>
    -                                                    <th>id</th>
    -                                                    <th>Name</th>
    -                                                    <th>Value</th>
    -                                                    <th>&nbsp;</th>
    -                                                </tr>
    -                                            </thead>
    -                                        </table>
    -                                    </div>
    -                                    <div id="oe-item-list-dialog-all-error">
    -                                        <h1>Data Unavailable</h1>
    -                                        <p>To display data here please ensure that the Overlay module is enabled and that the 'Enable debug mode' option is enabled within it.</p>
    -                                    </div>
    +                                <div id="oe-item-list-dialog-all-error">
    +                                    <h1>Data Unavailable</h1>
    +                                    <p>To display data here please ensure that the Overlay module is enabled and that the 'Enable debug mode' option is enabled within it.</p>
                                     </div>
                                 </div>
                             </div>
    -                        <div class="modal-footer">
    -                            <button type="button" class="btn btn-primary pull-left" id="oe-field-dialog-add-field">Add Variable</button>
    -                            <button type="button" class="btn btn-default" id="oe-item-list-dialog-close">Close</button>
    -                            <button type="button" class="btn btn-primary hidden" id="oe-item-list-dialog-save">Save Changes</button>
    -                        </div>
    +                    </div>
    +                    <div class="modal-footer">
    +                        <button type="button" class="btn btn-primary pull-left" id="oe-field-dialog-add-field">Add Variable</button>
    +                        <button type="button" class="btn btn-default" id="oe-item-list-dialog-close">Close</button>
    +                        <button type="button" class="btn btn-primary hidden" id="oe-item-list-dialog-save">Save Changes</button>
                         </div>
                     </div>
                 </div>
    +        </div>
     
    -            <div class="modal" id="oe-item-list-edit-dialog">
    -                <div class="modal-dialog" role="document">
    -                    <div class="modal-content">
    -                        <div class="modal-header">
    -                            <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
    -                            <h4 class="modal-title" id="oe-variable-edit-title">Edit Item</h4>
    -                        </div>
    -                        <div class="modal-body">
    -                            <p class="bg-danger oe-flash" id="oe-variable-edit-fash">You are editing a system field. You may only change the description, format and sample data values.</p>
    -                            <form id="oe-item-list-edit-dialog-form" class="form-horizontal">
    -                                <input type="hidden" id="oe-item-list-edit-dialog-id" name="oe-item-list-edit-dialog-id">
    -                                <div class="form-group">
    -                                    <label for="oe-item-list-edit-dialog-name" class="control-label col-xs-4">Variable Name</label>
    -                                    <div class="col-xs-8">
    -                                        <div class="input-group">
    -                                            <input id="oe-item-list-edit-dialog-name" name="oe-item-list-edit-dialog-name" class="form-control">
    -                                        </div>
    +        <div class="modal" id="oe-item-list-edit-dialog">
    +            <div class="modal-dialog" role="document">
    +                <div class="modal-content">
    +                    <div class="modal-header">
    +                        <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
    +                        <h4 class="modal-title" id="oe-variable-edit-title">Edit Item</h4>
    +                    </div>
    +                    <div class="modal-body">
    +                        <p class="bg-danger oe-flash" id="oe-variable-edit-fash">You are editing a system field. You may only change the description, format and sample data values.</p>
    +                        <form id="oe-item-list-edit-dialog-form" class="form-horizontal">
    +                            <input type="hidden" id="oe-item-list-edit-dialog-id" name="oe-item-list-edit-dialog-id">
    +                            <div class="form-group">
    +                                <label for="oe-item-list-edit-dialog-name" class="control-label col-xs-4">Variable Name</label>
    +                                <div class="col-xs-8">
    +                                    <div class="input-group">
    +                                        <input id="oe-item-list-edit-dialog-name" name="oe-item-list-edit-dialog-name" class="form-control">
                                         </div>
                                     </div>
    -                                <div class="form-group">
    -                                    <label for="oe-item-list-edit-dialog-description" class="control-label col-xs-4">Description</label>
    -                                    <div class="col-xs-8">
    -                                        <div class="input-group">
    -                                            <input id="oe-item-list-edit-dialog-description" name="oe-item-list-edit-dialog-description" class="form-control">
    -                                        </div>
    +                            </div>
    +                            <div class="form-group">
    +                                <label for="oe-item-list-edit-dialog-description" class="control-label col-xs-4">Description</label>
    +                                <div class="col-xs-8">
    +                                    <div class="input-group">
    +                                        <input id="oe-item-list-edit-dialog-description" name="oe-item-list-edit-dialog-description" class="form-control">
                                         </div>
                                     </div>
    -                                <div class="form-group">
    -                                    <label for="oe-item-list-edit-dialog-format" class="control-label col-xs-4">Format</label>
    -                                    <div class="col-xs-8">
    -                                        <div class="input-group">
    -                                            <input id="oe-item-list-edit-dialog-format" name="oe-item-list-edit-dialog-format" class="form-control">
    -                                        </div>
    +                            </div>
    +                            <div class="form-group">
    +                                <label for="oe-item-list-edit-dialog-format" class="control-label col-xs-4">Format</label>
    +                                <div class="col-xs-8">
    +                                    <div class="input-group">
    +                                        <input id="oe-item-list-edit-dialog-format" name="oe-item-list-edit-dialog-format" class="form-control">
                                         </div>
                                     </div>
    -                                <div class="form-group">
    -                                    <label for="oe-item-list-edit-dialog-sample" class="control-label col-xs-4">Sample Data</label>
    -                                    <div class="col-xs-8">
    -                                        <div class="input-group">
    -                                            <input id="oe-item-list-edit-dialog-sample" name="oe-item-list-edit-dialog-sample" class="form-control">
    -                                        </div>
    +                            </div>
    +                            <div class="form-group">
    +                                <label for="oe-item-list-edit-dialog-sample" class="control-label col-xs-4">Sample Data</label>
    +                                <div class="col-xs-8">
    +                                    <div class="input-group">
    +                                        <input id="oe-item-list-edit-dialog-sample" name="oe-item-list-edit-dialog-sample" class="form-control">
                                         </div>
                                     </div>
    -                                <div class="form-group">
    -                                    <label for="oe-item-list-edit-dialog-type" class="col-sm-4 control-label">Type</label>
    -                                    <div class="col-sm-8">
    -                                        <div class="input-group">
    -                                            <select class="form-control" id="oe-item-list-edit-dialog-type" name="oe-item-list-edit-dialog-type">
    -                                                <option value="Date">Date</option>
    -                                                <option value="Time">Time</option>
    -                                                <option value="Number">Number</option>
    -                                                <option value="Text">Text</option>
    -                                                <option value="Bool">Bool</option>
    -                                            </select>
    -                                        </div>
    +                            </div>
    +                            <div class="form-group">
    +                                <label for="oe-item-list-edit-dialog-type" class="col-sm-4 control-label">Type</label>
    +                                <div class="col-sm-8">
    +                                    <div class="input-group">
    +                                        <select class="form-control" id="oe-item-list-edit-dialog-type" name="oe-item-list-edit-dialog-type">
    +                                            <option value="Date">Date</option>
    +                                            <option value="Time">Time</option>
    +                                            <option value="Number">Number</option>
    +                                            <option value="Text">Text</option>
    +                                            <option value="Bool">Bool</option>
    +                                        </select>
                                         </div>
                                     </div>
    -                                <div class="form-group hidden">
    -                                    <label for="oe-item-list-edit-dialog-source" class="col-sm-4 control-label">Source</label>
    -                                    <div class="col-sm-8">
    -                                        <div class="input-group">
    -                                            <select class="form-control" id="oe-item-list-edit-dialog-source" name="oe-item-list-edit-dialog-source" disabled="disabled">
    -                                                <option value="System">System</option>
    -                                                <option value="User">User</option>
    -                                            </select>
    -                                        </div>
    +                            </div>
    +                            <div class="form-group hidden">
    +                                <label for="oe-item-list-edit-dialog-source" class="col-sm-4 control-label">Source</label>
    +                                <div class="col-sm-8">
    +                                    <div class="input-group">
    +                                        <select class="form-control" id="oe-item-list-edit-dialog-source" name="oe-item-list-edit-dialog-source" disabled="disabled">
    +                                            <option value="System">System</option>
    +                                            <option value="User">User</option>
    +                                        </select>
                                         </div>
                                     </div>
    -                            </form>
    -                        </div>
    -                        <div class="modal-footer">
    -                            <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
    -                            <button type="button" id="oe-field-save" class="btn btn-primary">Save changes</button>
    -                        </div>
    +                            </div>
    +                        </form>
    +                    </div>
    +                    <div class="modal-footer">
    +                        <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
    +                        <button type="button" id="oe-field-save" class="btn btn-primary">Save changes</button>
                         </div>
                     </div>
                 </div>
    +        </div>
     
    -            <div class="modal" role="dialog" id="fontlistdialog">
    -                <div class="modal-dialog modal-lg" role="document">
    -                    <div class="modal-content">
    -                        <div class="modal-header">
    -                            <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
    -                            <h4 class="modal-title">Font Manager</h4>
    -                        </div>
    -                        <div class="modal-body">
    -                            <table id="fontlisttable" class="display compact" style="width:98%">
    -                                <thead>
    -                                    <tr>
    -                                        <th>Name</th>
    -                                        <th>Path</th>
    -                                        <th>&nbsp;</th>
    -                                    </tr>
    -                                </thead>
    -                            </table>
    -                        </div>
    -                        <div class="modal-footer">
    -                            <button type="button" class="btn btn-primary pull-left" id="oe-font-dialog-add-font">Add Font</button>
    -                            <button type="button" class="btn btn-primary pull-left" id="oe-font-dialog-upload-font">Upload Font</button>
    -                            <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
    -                        </div>
    +        <div class="modal" role="dialog" id="fontlistdialog">
    +            <div class="modal-dialog modal-lg" role="document">
    +                <div class="modal-content">
    +                    <div class="modal-header">
    +                        <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
    +                        <h4 class="modal-title">Font Manager</h4>
    +                    </div>
    +                    <div class="modal-body">
    +                        <table id="fontlisttable" class="display compact" style="width:98%">
    +                            <thead>
    +                                <tr>
    +                                    <th>Name</th>
    +                                    <th>Path</th>
    +                                    <th>&nbsp;</th>
    +                                </tr>
    +                            </thead>
    +                        </table>
    +                    </div>
    +                    <div class="modal-footer">
    +                        <button type="button" class="btn btn-primary pull-left" id="oe-font-dialog-add-font">Add Font</button>
    +                        <button type="button" class="btn btn-primary pull-left" id="oe-font-dialog-upload-font">Upload Font</button>
    +                        <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
                         </div>
                     </div>
                 </div>
    +        </div>
     
    -            <div class="modal" role="dialog" id="oe-file-manager-dialog">
    -                <div class="modal-dialog modal-lg" role="document">
    -                    <div class="modal-content">
    -                        <div class="modal-header">
    -                            <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
    -                            <h4 class="modal-title">Image Manager</h4>
    -                        </div>
    -                        <div class="modal-body">
    -                            <div id="oe-image-manager"></div>
    -                        </div>
    -                        <div class="modal-footer">
    -                            <button type="button" class="btn btn-default" id="oe-file-manager-dialog-close" data-dismiss="modal">Close</button>
    -                        </div>
    +        <div class="modal" role="dialog" id="oe-file-manager-dialog">
    +            <div class="modal-dialog modal-lg" role="document">
    +                <div class="modal-content">
    +                    <div class="modal-header">
    +                        <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
    +                        <h4 class="modal-title">Image Manager</h4>
    +                    </div>
    +                    <div class="modal-body">
    +                        <div id="oe-image-manager"></div>
    +                    </div>
    +                    <div class="modal-footer">
    +                        <button type="button" class="btn btn-default" id="oe-file-manager-dialog-close" data-dismiss="modal">Close</button>
                         </div>
                     </div>
                 </div>
    +        </div>
     
    -            <div class="modal" role="dialog" id="oe-debug-dialog">
    -                <div class="modal-dialog modal-lg" role="document">
    -                    <div class="modal-content">
    -                        <div class="modal-header">
    -                            <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
    -                            <h4 class="modal-title">Debug Info</h4>
    -                        </div>
    -                        <div class="modal-body">
    -                            <form id="oe-debug-dialog-form" class="form-horizontal">
    -                                <div class="form-group">
    -                                    <label for="oe-debug-dialog-overlay" class="col-sm-2 control-label">Overlay Data</label>
    -                                    <div class="col-sm-10">
    -                                        <div class="input-group">
    -                                            <textarea id="oe-debug-dialog-overlay" name="oe-debug-dialog-overlay" rows="10" cols="80" disabled="disabled"></textarea>
    -                                        </div>
    +        <div class="modal" role="dialog" id="oe-debug-dialog">
    +            <div class="modal-dialog modal-lg" role="document">
    +                <div class="modal-content">
    +                    <div class="modal-header">
    +                        <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
    +                        <h4 class="modal-title">Debug Info</h4>
    +                    </div>
    +                    <div class="modal-body">
    +                        <form id="oe-debug-dialog-form" class="form-horizontal">
    +                            <div class="form-group">
    +                                <label for="oe-debug-dialog-overlay" class="col-sm-2 control-label">Overlay Data</label>
    +                                <div class="col-sm-10">
    +                                    <div class="input-group">
    +                                        <textarea id="oe-debug-dialog-overlay" name="oe-debug-dialog-overlay" rows="10" cols="80" disabled="disabled"></textarea>
                                         </div>
                                     </div>
    -                                <div class="form-group">
    -                                    <label for="oe-debug-dialog-fields" class="col-sm-2 control-label">Field Data</label>
    -                                    <div class="col-sm-10">
    -                                        <div class="input-group">
    -                                            <textarea id="oe-debug-dialog-fields" name="oe-debug-dialog-fields" rows="10" cols="80" disabled="disabled"></textarea>
    -                                        </div>
    +                            </div>
    +                            <div class="form-group">
    +                                <label for="oe-debug-dialog-fields" class="col-sm-2 control-label">Field Data</label>
    +                                <div class="col-sm-10">
    +                                    <div class="input-group">
    +                                        <textarea id="oe-debug-dialog-fields" name="oe-debug-dialog-fields" rows="10" cols="80" disabled="disabled"></textarea>
                                         </div>
                                     </div>
    -                                <div class="form-group">
    -                                    <label for="oe-debug-dialog-config" class="col-sm-2 control-label">Editor Config</label>
    -                                    <div class="col-sm-10">
    -                                        <div class="input-group">
    -                                            <textarea id="oe-debug-dialog-config" name="oe-debug-dialog-config" rows="10" cols="80" disabled="disabled"></textarea>
    -                                        </div>
    +                            </div>
    +                            <div class="form-group">
    +                                <label for="oe-debug-dialog-config" class="col-sm-2 control-label">Editor Config</label>
    +                                <div class="col-sm-10">
    +                                    <div class="input-group">
    +                                        <textarea id="oe-debug-dialog-config" name="oe-debug-dialog-config" rows="10" cols="80" disabled="disabled"></textarea>
                                         </div>
    -                                </div>                                
    -                            </form>                          
    -                        </div>
    -                        <div class="modal-footer">
    -                            <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
    -                        </div>
    +                                </div>
    +                            </div>
    +                        </form>
    +                    </div>
    +                    <div class="modal-footer">
    +                        <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
                         </div>
                     </div>
                 </div>
    -
    -        </div>
    +        </div> <!-- ./row --> </div> <!-- /.oeeditor -->
     
             <div class="modal" tabindex="-1" id="optionsdialog">
                 <div class="modal-dialog modal-lg" role="document">
    @@ -458,8 +518,9 @@ function DisplayOverlay($image_name)
     
                                 <!-- Nav tabs -->
                                 <ul class="nav nav-tabs" role="tablist">
    -                                <li role="presentation" class="active"><a href="#configoptions" aria-controls="configoptions" role="tab" data-toggle="tab">Layout Defaults</a></li>
    +                                <li role="presentation" class="active"><a href="#configoptions" aria-controls="configoptions" role="tab" data-toggle="tab" id="oe-editor-layout-defaults">Layout Defaults</a></li>
                                     <li role="presentation"><a href="#oeeditoroptions" aria-controls="oeeditoroptions" role="tab" data-toggle="tab">Editor Settings</a></li>
    +                                <li role="presentation"><a href="#oeeditoroverlays" aria-controls="oeeditoroverlays" role="tab" data-toggle="tab">Overlays</a></li>                                
                                 </ul>
     
                                 <!-- Tab panes -->
    @@ -473,7 +534,7 @@ function DisplayOverlay($image_name)
                                                     Opacity</label>
                                                 <div class="col-xs-8">
                                                     <div class="input-group">
    -                                                    <input id="defaultimagetopacity" name="defaultimagetopacity" type="number" min="0" max="100" step="1" class="form-control">
    +                                                    <input id="defaultimagetopacity" name="defaultimagetopacity" type="number" min="0" max="100" step="1" class="form-control layoutfield">
                                                     </div>
                                                 </div>
                                             </div>
    @@ -482,7 +543,7 @@ function DisplayOverlay($image_name)
                                                     Rotation</label>
                                                 <div class="col-xs-8">
                                                     <div class="input-group">
    -                                                    <input id="defaultimagerotation" name="defaultimagerotation" type="number" min="0" max="359" step="1" class="form-control">
    +                                                    <input id="defaultimagerotation" name="defaultimagerotation" type="number" min="0" max="359" step="1" class="form-control layoutfield">
                                                     </div>
                                                 </div>
                                             </div>
    @@ -490,7 +551,7 @@ function DisplayOverlay($image_name)
                                                 <label for="defaultfont" class="control-label col-xs-4">Default Font</label>
                                                 <div class="col-xs-8">
                                                     <div class="input-group">
    -                                                    <select id="defaultfont" name="defaultfont" class="form-control">
    +                                                    <select id="defaultfont" name="defaultfont" class="form-control layoutfield">
                                                         </select>
                                                     </div>
                                                 </div>
    @@ -500,7 +561,7 @@ function DisplayOverlay($image_name)
                                                     Size</label>
                                                 <div class="col-xs-8">
                                                     <div class="input-group">
    -                                                    <input id="defaultfontsize" name="defaultfontsize" type="number" min="8" max="64" step="1" class="form-control">
    +                                                    <input id="defaultfontsize" name="defaultfontsize" type="number" min="8" max="64" step="1" class="form-control layoutfield">
                                                     </div>
                                                 </div>
                                             </div>
    @@ -509,7 +570,7 @@ function DisplayOverlay($image_name)
                                                     Opacity</label>
                                                 <div class="col-xs-8">
                                                     <div class="input-group">
    -                                                    <input id="defaultfontopacity" name="defaultfontopacity" type="number" min="0" max="100" step="1" class="form-control">
    +                                                    <input id="defaultfontopacity" name="defaultfontopacity" type="number" min="0" max="100" step="1" class="form-control layoutfield">
                                                     </div>
                                                 </div>
                                             </div>
    @@ -518,7 +579,7 @@ function DisplayOverlay($image_name)
                                                     Colour</label>
                                                 <div class="col-xs-8">
                                                     <div class="input-group">
    -                                                    <input id="oe-default-font-colour" name="oe-default-font-colour" type="input" class="form-control">
    +                                                    <input id="oe-default-font-colour" name="oe-default-font-colour" type="input" class="form-control layoutfield">
                                                     </div>
                                                 </div>
                                             </div>
    @@ -527,7 +588,7 @@ function DisplayOverlay($image_name)
                                                     Rotation</label>
                                                 <div class="col-xs-8">
                                                     <div class="input-group">
    -                                                    <input id="defaulttextrotation" name="defaulttextrotation" type="number" min="0" max="359" step="1" class="form-control">
    +                                                    <input id="defaulttextrotation" name="defaulttextrotation" type="number" min="0" max="359" step="1" class="form-control layoutfield">
                                                     </div>
                                                 </div>
                                             </div>
    @@ -536,25 +597,25 @@ function DisplayOverlay($image_name)
                                                     Colour</label>
                                                 <div class="col-xs-8">
                                                     <div class="input-group">
    -                                                    <input id="oe-default-stroke-colour" name="oe-default-stroke-colour" type="input" class="form-control">
    +                                                    <input id="oe-default-stroke-colour" name="oe-default-stroke-colour" type="input" class="form-control layoutfield">
                                                     </div>
                                                 </div>
    -                                        </div>                                                                          
    +                                        </div>
                                             <div class="form-group">
                                                 <label for="defaultdatafileexpiry" class="control-label col-xs-4">Default Extra Data Expiry</label>
                                                 <div class="col-xs-8">
                                                     <div class="input-group">
    -                                                    <input id="defaultdatafileexpiry" name="defaultdatafileexpiry" type="number" min="0" max="60000" step="10" class="form-control">
    +                                                    <input id="defaultdatafileexpiry" name="defaultdatafileexpiry" type="number" min="0" max="60000" step="10" class="form-control layoutfield">
                                                     </div>
                                                     <p class="help-block">This is the default expiry time in seconds for the extra data files. This can be overriden for each variable in the data files, see the documentation for more details</p>
                                                 </div>
                                             </div>
     
                                             <div class="form-group">
    -                                            <label for="defaultdatafileexpiry" class="control-label col-xs-4">Expiry Text</label>
    +                                            <label for="defaultexpirytext" class="control-label col-xs-4">Expiry Text</label>
                                                 <div class="col-xs-8">
                                                     <div class="input-group">
    -                                                    <input id="defaultexpirytext" name="defaultexpirytext" type="text" class="form-control">
    +                                                    <input id="defaultexpirytext" name="defaultexpirytext" type="text" class="form-control layoutfield">
                                                     </div>
                                                     <p class="help-block">If blank then any expired fields will be removed. If non blank the value of an expired field will be replaced with this text</p>
                                                 </div>
    @@ -564,7 +625,7 @@ function DisplayOverlay($image_name)
                                                 <label for="defaultnoradids" class="control-label col-xs-4">Norad ID's</label>
                                                 <div class="col-xs-8">
                                                     <div class="input-group">
    -                                                    <input id="defaultnoradids" name="defaultnoradids" type="text" class="form-control">
    +                                                    <input id="defaultnoradids" name="defaultnoradids" type="text" class="form-control layoutfield">
                                                     </div>
                                                     <p class="help-block">List of NORAD Id's to calculate satellite positions for, satellite Id's can be found on the <a href="https://celestrak.org/satcat/search.php" target="_blank">Celestrak</a> website. See the documentaiton for more details</p>
                                                 </div>
    @@ -573,25 +634,25 @@ function DisplayOverlay($image_name)
                                                 <div class="col-sm-offset-4 col-sm-2">
                                                     <div class="checkbox">
                                                         <label>
    -                                                        <input type="checkbox" id="defaultincludeplanets"> Include Planets
    +                                                        <input type="checkbox" id="defaultincludeplanets" class="layoutfield"> Include Planets
                                                         </label>
                                                     </div>
                                                 </div>
                                                 <div class="col-sm-2">
                                                     <div class="checkbox">
                                                         <label>
    -                                                        <input type="checkbox" id="defaultincludesun"> Include Sun
    +                                                        <input type="checkbox" id="defaultincludesun" class="layoutfield"> Include Sun
                                                         </label>
                                                     </div>
    -                                            </div>  
    +                                            </div>
                                                 <div class="col-sm-4">
                                                     <div class="checkbox">
                                                         <label>
    -                                                        <input type="checkbox" id="defaultincludemoon"> Include Moon
    +                                                        <input type="checkbox" id="defaultincludemoon" class="layoutfield" class="layoutfield"> Include Moon
                                                         </label>
                                                     </div>
    -                                            </div>                                                                                         
    -                                        </div>                                                                             
    +                                            </div>
    +                                        </div>
                                         </form>
     
                                     </div>
    @@ -636,7 +697,7 @@ function DisplayOverlay($image_name)
                                                         <input id="oe-app-options-grid-colour" name="oe-app-options-grid-colour" type="input" class="form-control">
                                                     </div>
                                                 </div>
    -                                        </div>                                        
    +                                        </div>
                                             <div class="form-group">
                                                 <label for="oe-app-options-grid-opacity" class="col-sm-4 control-label">Grid Brightness</label>
                                                 <div class="col-sm-8">
    @@ -713,10 +774,28 @@ function DisplayOverlay($image_name)
                                                     </div>
                                                     <p class="help-block">0 = Lowest, 100 = Brightest</p>
                                                 </div>
    -                                        </div>                                       
    +                                        </div>
                                         </form>
     
                                     </div>
    +
    +                                <div role="tabpanel" class="tab-pane" id="oeeditoroverlays">
    +                                    <div class="mt-2">
    +                                        <table id="overlaytablelist" style="width:100%">
    +                                            <thead>
    +                                                <tr>
    +                                                    <th>Type</th>                                                
    +                                                    <th>Name</th>
    +                                                    <th>Brand</th>
    +                                                    <th>Model</th>
    +                                                    <th>TOD</th>
    +                                                    <th></th>
    +                                                </tr>
    +                                            </thead>
    +                                        </table> 
    +                                    </div>
    +                                </div>
    +                                
                                 </div>
                             </div>
     
    @@ -762,22 +841,50 @@ function DisplayOverlay($image_name)
                 </div>
             </div>
     
    -        <img id="oe-background-image" class="oe-background-image" src="<?php echo $image_name ?>" style="width:100%">
    -
    -        <script type="module">
    -            var imageObj = new Image();
    -            imageObj.src = $('#oe-background-image').attr('src');
    -
    -            let that = this;
    -            imageObj.onload = function() {
    -                var overlayEditor = new OVERLAYEDITOR($("#overlay_container"), this);
    -                overlayEditor.buildUI();
    -
    -<?php if ($displayMaskTab) { ?>
    -                var exposureEditor = new OEEXPOSURE();
    -                exposureEditor.start();
    -<?php } ?>
    -            };
    +        <img id="oe-background-image" class="oe-background-image" alt="Overlay Image" src="<?php echo $image_name ?>" style="width:100%">
    +
    +        <script>
    +            startOverlayManager();
    +
    +            function startOverlayManager() {
    +                let result = $.ajax({
    +                    url: 'includes/overlayutil.php?request=Status',
    +                    type: 'GET',
    +                    dataType: 'json',
    +                    cache: false,
    +                    async: false,                
    +                    context: this
    +                });
    +
    +                let startOverlay = true;
    +                if (result.responseJSON !== undefined) {
    +                    if (result.responseJSON.running !== undefined) {
    +                        startOverlay = result.responseJSON.running;
    +                        $('#oe-overlay-not-running-status').html('Status: <em>' + result.responseJSON.status + '</em>');
    +                    }
    +                }
    +                
    +                if (startOverlay) {
    +                    $('#oe-overlay-not-running').addClass('hidden');
    +                    var imageObj = new Image();
    +                        imageObj.src = $('#oe-background-image').attr('src') + '?_ts=' + new Date().getTime();
    +
    +                        let that = this;
    +                        imageObj.onload = function() {
    +                            var overlayEditor = new OVERLAYEDITOR($("#overlay_container"), this);
    +                            overlayEditor.buildUI();
    +
    +                            <?php if ($displayMaskTab) { ?>
    +                                var exposureEditor = new OEEXPOSURE();
    +                                exposureEditor.start();
    +                            <?php } ?>
    +                        };                    
    +                } else {
    +                    $('#oe-overlay-not-running').removeClass('hidden');
    +                    setTimeout(startOverlayManager, 1000);
    +                }
    +            }
    +           
             </script>
     
         <?php
    diff --git a/html/includes/overlayutil.php b/html/includes/overlayutil.php
    index 8d427d6a5..36493de17 100644
    --- a/html/includes/overlayutil.php
    +++ b/html/includes/overlayutil.php
    @@ -3,9 +3,6 @@
     include_once('functions.php');
     initialize_variables();		// sets some variables
     
    -define('RASPI_ADMIN_DETAILS', RASPI_CONFIG . '/raspap.auth');
    -
    -include_once('raspap.php');
     include_once('authenticate.php');
     
     class OVERLAYUTIL
    @@ -14,7 +11,9 @@ class OVERLAYUTIL
         private $method;
         private $jsonResponse = false;
         private $overlayPath;
    +    private $allskyOverlays;
         private $allskyTmp;
    +    private $allskyStatus;    
         private $cc = "";
         private $excludeVariables = array(
             "\${TEMPERATURE_C}" => array(
    @@ -24,13 +23,15 @@ class OVERLAYUTIL
             "\${TEMPERATURE_F}" => array(
                 "ccfield" => "hasSensorTemperature",
                 "value" => false,
    -        )            
    +        )
         );
     
         public function __construct()
         {
             $this->overlayPath = ALLSKY_OVERLAY;
    +        $this->allskyOverlays = MY_OVERLAY_TEMPLATES . '/';
             $this->allskyTmp = ALLSKY_HOME . '/tmp';
    +        $this->allskyStatus = ALLSKY_CONFIG . '/status.json';
     
             $ccFile = ALLSKY_CONFIG . "/cc.json";
             $ccJson = file_get_contents($ccFile, true);
    @@ -147,8 +148,15 @@ public function getConfig()
     
         public function postConfig()
         {
    -        $fileName = $this->overlayPath . '/config/overlay.json';
    -        $config = $_POST["config"];
    +        $overlayName = $_POST['overlay']['name'];
    +        $overlayType = $_POST['overlay']['type'];
    +
    +        if ($overlayType === 'user') {
    +            $fileName = $this->allskyOverlays . $overlayName;
    +        } else {
    +            $fileName = $this->overlayPath . '/config/' . $overlayName;
    +        }
    +        $config = $_POST['config'];
             $formattedJSON = json_encode(json_decode($config), JSON_PRETTY_PRINT);
             $bytesWritten = file_put_contents($fileName, $formattedJSON);
             if ($bytesWritten === false) {
    @@ -158,24 +166,39 @@ public function postConfig()
             }
         }
     
    -    public function getAppConfig()
    +    public function getAppConfig($returnResult=false)
         {
             $fileName = $this->overlayPath . '/config/oe-config.json';
             $config = file_get_contents($fileName);
             if ($config === false) {
                 $config = '{
    -        "gridVisible": true,
    -        "gridSize": 10,
    -        "gridOpacity": 30,
    -        "snapBackground": true,
    -        "addlistpagesize": 20,
    -        "addfieldopacity": 15,
    -        "selectfieldopacity": 30,
    -        "mousewheelzoom": false,
    -        "backgroundopacity": 40
    -      }';
    +                "gridVisible": true,
    +                "gridSize": 10,
    +                "gridOpacity": 30,
    +                "snapBackground": true,
    +                "addlistpagesize": 20,
    +                "addfieldopacity": 15,
    +                "selectfieldopacity": 30,
    +                "mousewheelzoom": false,
    +                "backgroundopacity": 40
    +            }';
    +        }
    +
    +        $config = json_decode($config);
    +        if (!isset($config->overlayErrors)) {
    +            $config->overlayErrors = true;
    +        }
    +        if (!isset($config->overlayErrorsText)) {
    +            $config->overlayErrorsText = 'Error found; see the WebU';
    +        }
    +        $config = json_encode($config);
    +
    +        if (!$config) {
    +            $this->sendResponse($config);
    +        } else {
    +            $config = json_decode($config);
    +            return $config;
             }
    -        $this->sendResponse($config);
         }
     
         public function postAppConfig()
    @@ -201,12 +224,12 @@ private function includeField($field) {
             return $result;
         }
     
    -    public function getData()
    +    public function getData($returnResult=false)
         {
             $fileName = $this->overlayPath . '/config/fields.json';
             $fields = file_get_contents($fileName);
             $systemData = json_decode($fields);
    -        
    +
             $fileName = $this->overlayPath . '/config/userfields.json';
             $fields = file_get_contents($fileName);
             $userData = json_decode($fields);
    @@ -215,7 +238,7 @@ public function getData()
             $mergedFields = array();
     
             foreach($systemData->data as $systemField) {
    -            if ($this->includeField($systemField->name)) { 
    +            if ($this->includeField($systemField->name)) {
                     $field = array(
                         "id" => $counter,
                         "name" => $systemField->name,
    @@ -232,7 +255,7 @@ public function getData()
     
             foreach($userData->data as $userField) {
     
    -            if ($this->includeField($systemField->name)) {          
    +            if ($this->includeField($systemField->name)) {
                     $field = array(
                         "id" => $counter,
                         "name" => $userField->name,
    @@ -251,8 +274,13 @@ public function getData()
                 "data" => $mergedFields
             );
             $jsonFields = json_encode($fields);
    -        
    -        $this->sendResponse($jsonFields);
    +
    +        if (!$returnResult) {
    +            $jsonFields = json_encode($fields);
    +            $this->sendResponse($jsonFields);
    +        } else {
    +            return $fields;
    +        }
         }
     
         public function postData()
    @@ -285,7 +313,7 @@ public function postData()
             $this->sendResponse();
         }
     
    -    public function getOverlayData() {
    +    public function getOverlayData($returnResult=false) {
             $result = [];
             $fileName = ALLSKY_HOME . '/tmp/overlaydebug.txt';
     
    @@ -323,8 +351,13 @@ public function getOverlayData() {
                     $result['data'] = $fieldData;
                 }
             }
    -        $data = json_encode($result, JSON_PRETTY_PRINT);
    -        $this->sendResponse($data);
    +
    +        if (!$returnResult) {
    +            $data = json_encode($result, JSON_PRETTY_PRINT);
    +            $this->sendResponse($data);
    +        } else {
    +            return $result;
    +        }        
         }
     
         public function getAutoExposure()
    @@ -373,24 +406,9 @@ private function processDebugData() {
             return $exampleData;
         }
     
    -    public function getFonts()
    -    {
    -        $fileName = $this->overlayPath . '/config/overlay.json';
    -        $config = file_get_contents($fileName);
    -        $config = json_decode($config);
    +    public function getFonts() {
     
    -        $fields = [];
             $count = 1;
    -        foreach ($config->fonts as $name => $font) {
    -            $obj = (object) [
    -                'id' => $count,
    -                'name' => $name,
    -                'path' => $font->fontPath,
    -            ];
    -            $fields[] = $obj;
    -            $count++;
    -        }
    -
             $usableFonts = array(
                 'Arial' => array('fontpath' => '/usr/share/fonts/truetype/msttcorefonts/Arial.ttf'),
                 'Arial Black' => array('fontpath' => '/usr/share/fonts/truetype/msttcorefonts/Arial_Black.ttf'),
    @@ -413,12 +431,26 @@ public function getFonts()
                 $count++;
             }
     
    +        $fontDir = $this->overlayPath . '/fonts/';
    +        $fontList = scandir($fontDir);
    +        foreach ($fontList as $font) {
    +            if ($font !== '.' && $font !== '..') {
    +                $obj = (object) [
    +                    'id' => $count,
    +                    'name' => basename($font),
    +                    'path' => '/fonts/' . $font,
    +                ];
    +            }
    +            $fields[] = $obj;
    +            $count++;
    +        }
    +
             $data = array(
                 'data' => $fields,
             );
     
             $data = json_encode($data, JSON_PRETTY_PRINT);
    -        $this->sendResponse($data);
    +        $this->sendResponse($data);        
         }
     
         public function postFonts()
    @@ -452,7 +484,7 @@ public function postFonts()
             }
     
             if ($proceed) {
    -            $saveFolder = $this->overlayPath . "/fonts/";          
    +            $saveFolder = $this->overlayPath . "/fonts/";
                 $result = array();
                 $zipArchive = new ZipArchive();
                 $zipArchive->open($downloadPath);
    @@ -488,7 +520,12 @@ public function postFonts()
                                     fwrite($file, $contents);
                                     fclose($file);
     
    -                                $configFileName = $this->overlayPath . '/config/overlay.json';
    +                                $fontPath = str_replace($this->overlayPath, "", $fileName);
    +                                $key = basename($fileName);
    +                                $key = str_replace($validExtenstions, "", $key);
    +                                $key = str_replace(".", "", $key);
    +
    +                               /* $configFileName = $this->overlayPath . '/config/overlay.json';
                                     $config = file_get_contents($configFileName);
                                     $config = json_decode($config);
     
    @@ -505,6 +542,7 @@ public function postFonts()
                                     $formattedJSON = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
     
                                     file_put_contents($configFileName, $formattedJSON);
    +                                */
     
                                     $result[] = array(
                                         'key' => $key,
    @@ -621,6 +659,380 @@ public function getFormats()
             $this->sendResponse($data);
         }
     
    +    public function getConfigs() {
    +        $result = [];
    +
    +        $tod = getenv('DAY_OR_NIGHT');
    +        if ($tod === false) {
    +            $tod = 'night';
    +        }
    +        if ($tod == 'day') {
    +            $overlayFilename = $this->getSetting('daytimeoverlay');
    +        } else {
    +            $overlayFilename = $this->getSetting('nighttimeoverlay');
    +        }
    +
    +        $template = null;
    +        $fileName = $this->overlayPath . '/config/' . $overlayFilename;
    +        if (file_exists($fileName)) {
    +            $template = file_get_contents($fileName);
    +        } else {
    +            $fileName = $this->allskyOverlays . $overlayFilename;
    +            if (file_exists($fileName)) {            
    +                $template = file_get_contents($fileName);
    +            }
    +        }
    +        $templateData = json_decode($template);      
    +        $this->fixMetaData($templateData);     
    +        $result['config'] = $templateData;
    +
    +        $data = $this->getData(true);
    +        $result['data'] = $data;
    +
    +        $data = $this->getOverlayData(true);
    +        $result['overlaydata'] = $data;        
    +
    +        $data = $this->getAppConfig(true);
    +        $result['appconfig'] = $data;        
    +        
    +        $result = json_encode($result);
    +        $this->sendResponse($result);
    +    }
    +
    +    public function getLoadOverlay($overlayName=null, $return=false) {
    +        if ($overlayName === null) {
    +            $overlayName = $_GET['overlay'];
    +        }
    +        $fileName = $this->overlayPath . '/config/' . $overlayName;
    +
    +        $overlay = null;
    +        if (file_exists($fileName)) {
    +            $overlay = file_get_contents($fileName);
    +        } else {
    +            $fileName = $this->allskyOverlays . $overlayName;
    +            if (file_exists($fileName)) {
    +                $overlay = file_get_contents($fileName);
    +            }
    +        }     
    +
    +        if (!$return) {
    +            $this->sendResponse($overlay);
    +        } else {
    +            return $overlay;
    +        }
    +
    +    }
    +
    +    private function getSetting($name, $swapSpaces='') {
    +        global $settings_array;
    +        $name = getVariableOrDefault($settings_array, $name, 'overlay.json');
    +        if ($swapSpaces !== '') {
    +            $name = str_replace(' ',$swapSpaces, $name);
    +        }
    +        return $name;
    +    }
    +
    +    public function getOverlays() {
    +        $overlayData = [];
    +        $overlayData['coreoverlays'] = [];
    +        $overlayData['useroverlays'] = [];
    +        $overlayData['config'] = [];
    +        $overlayData['config']['daytime'] = $this->getSetting('daytimeoverlay');
    +        $overlayData['config']['nighttime'] = $this->getSetting('nighttimeoverlay');
    +        $overlayData['brands'] = ['RPi', 'ZWO', 'Arducam'];
    +        $overlayData['brand'] = $this->getSetting('cameratype');
    +        $overlayData['model'] = $this->getSetting('cameramodel');
    +        $overlayData['sensorWidth'] = $this->cc['sensorWidth'];
    +        $overlayData['sensorHeight'] = $this->cc['sensorHeight'];
    +
    +        $tod = getTOD();
    +        if ($tod == 'day') {
    +            $overlayData['current'] = $overlayData['config']['daytime'];
    +        } else {
    +            $overlayData['current'] = $overlayData['config']['nighttime'];
    +        }
    +
    +        $defaultDir = $this->overlayPath . '/config/';
    +        $entries = scandir($defaultDir);
    +        foreach ($entries as $entry) {
    +            if ($entry !== '.' && $entry !== '..') {
    +                if (substr($entry,0, 7) === 'overlay') {
    +                    $templatePath = $defaultDir . $entry;
    +                    $template = file_get_contents($templatePath);
    +                    $templateData = json_decode($template);
    +                    $this->fixMetaData($templateData);
    +                    if (!isset($templateData->metadata)) {
    +                        $name = 'Unknown';
    +                        switch ($entry) {
    +                            case 'overlay.json':
    +                                $name = 'Default Overlay';
    +                                break;
    +
    +                            case 'overlay-RPi.json':
    +                                $name = 'Default RPi Overlay';
    +                                break;
    +
    +                            case 'overlay-ZWO.json':
    +                                $name = 'Default ZWO Overlay';
    +                                break;
    +                        }
    +                        $templateData->metadata = [];
    +                        $templateData->metadata['name'] = $name;
    +                    }
    +                    $overlayData['coreoverlays'][$entry] = $templateData;
    +                    
    +                }
    +            }
    +        }
    +
    +
    +        $userDir = $this->allskyOverlays;
    +        $entries = scandir($userDir);
    +        foreach ($entries as $entry) {
    +            if ($entry !== '.' && $entry !== '..') {
    +                if (substr($entry,0, 7) === 'overlay') {
    +                    $templatePath = $userDir . $entry;
    +                    if (is_file($templatePath)) {
    +                        $template = file_get_contents($templatePath);
    +                        $templateData = json_decode($template);
    +                        $this->fixMetaData($templateData);
    +                        $overlayData['useroverlays'][$entry] = $templateData;
    +                    }
    +                }
    +            }
    +        }
    +
    +        $settingsFile = ALLSKY_CONFIG . '/settings.json';
    +        $settings = file_get_contents($settingsFile);
    +        $settings = json_decode($settings);
    +        $overlayData['settings'] = $settings;
    +
    +        $overlayData = json_encode($overlayData);
    +        $this->sendResponse($overlayData);
    +    }
    +
    +    public function getValidateFilename() {
    +        $fileName = $_GET['filename'];
    +        $userDir = $this->allskyOverlays;
    +        $filePath = $userDir . $fileName;
    +        $fileExists = false;
    +
    +        if (is_file($filePath)) {
    +            $fileExists = true;
    +        }
    +
    +        $result = [
    +            'error' => $fileExists
    +        ];
    +        $result = json_encode($result);
    +        $this->sendResponse($result);
    +    }
    +
    +    public function getSuggest() {
    +        $userDir = $this->allskyOverlays;
    +        $maxFound = 0;
    +
    +        $entries = scandir($userDir);
    +        foreach ($entries as $entry) {
    +            if ($entry !== '.' && $entry !== '..') {
    +                $entryBits = explode('-', $entry);
    +                if (count($entryBits) > 0) {
    +                    if (substr($entryBits[0],0,7) === 'overlay') {
    +                        $num = intval(substr($entryBits[0],7));
    +                        if ($num > $maxFound) {
    +                            $maxFound = $num;
    +                        }
    +
    +                    }
    +                }
    +            }
    +        }
    +        $maxFound++;
    +
    +        $this->sendResponse($maxFound);        
    +    }
    +
    +    public function postNewOverlay() {
    +
    +        if (!file_exists($this->allskyOverlays)) {
    +            mkdir($this->allskyOverlays);
    +        }
    +
    +        $copyOverlay = $_POST['data']['copy'];
    +        if ($copyOverlay !== 'none') {
    +            $newOverlay = $this->getLoadOverlay($copyOverlay, true);
    +            $newOverlay = json_decode($newOverlay);
    +        } else {
    +            $newOverlay = (object)null;
    +            $this->fixMetaData($newOverlay);
    +        }
    +
    +        $newOverlay->metadata = $_POST['fields'];
    +
    +        if (!isset($newOverlay->fields)) {
    +            $newOverlay->fields = [];
    +        }
    +        if (!isset($newOverlay->images)) {
    +            $newOverlay->images = [];
    +        }
    +        if (!isset($newOverlay->fonts)) {
    +            $newOverlay->fonts = [
    +                'moon_phases' => [
    +                    'fontPath' => 'fonts/moon_phases.ttf'
    +                ]
    +            ];
    +        }
    +        if (!isset($newOverlay->settings)) {
    +            $newOverlay->settings = [
    +                'defaultdatafileexpiry' => '550',
    +                'defaultincludeplanets' => false,
    +                'defaultincludesun' => false,
    +                'defaultincludemoon' => false,
    +                'defaultimagetopacity' => 0.63,
    +                'defaultimagerotation' => 0,
    +                'defaulttextrotation' => 0,
    +                'defaultfontopacity' => 1,
    +                'defaultfontcolour' => 'white',
    +                'defaultfont' => 'Arial',
    +                'defaultfontsize' => 52,
    +                'defaultimagescale' => 1,
    +                'defaultnoradids' => ''                
    +            ];
    +        }            
    +                
    +        $newOverlay = json_encode($newOverlay, JSON_PRETTY_PRINT);
    +
    +        $overlayFile = $this->allskyOverlays . $_POST['data']['filename'] . '.json';
    +        file_put_contents($overlayFile, $newOverlay);
    +        chmod($overlayFile, 0775);
    +        $this->sendResponse();
    +    }
    +
    +    public function getDeleteOverlay() {
    +        $fileName = $_GET['filename'];        
    +        $overlayFile = $this->allskyOverlays . $fileName;
    +        if (file_exists($overlayFile)) {
    +            unlink($overlayFile);
    +        }
    +        $this->sendResponse();
    +    }
    +
    +    public function postSaveSettings() {
    +        $dayTime = $_POST['daytime'];
    +        $nightTime = $_POST['nighttime'];
    +
    +        $settingsFile = getSettingsFile();
    +        $data = file_get_contents($settingsFile, true);
    +        $settingsData = json_decode($data, true);
    +        $settingsData['daytimeoverlay'] = $dayTime;
    +        $settingsData['nighttimeoverlay'] = $nightTime;
    +
    +        $mode = JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_NUMERIC_CHECK|JSON_PRESERVE_ZERO_FRACTION;
    +        $data = json_encode($settingsData, $mode);
    +
    +        $result = file_put_contents($settingsFile, $data);
    +                
    +        $this->sendResponse($result);
    +
    +    }
    +
    +    private function fixMetaData(&$overlay) {
    +        if ($overlay !== null) {
    +            if (!isset($overlay->metadata)) {
    +                $overlay->metadata = (object)null;
    +            }
    +            if (!isset($overlay->metadata->name)) {
    +                $overlay->metadata->name = '???';
    +            }
    +            if (!isset($overlay->metadata->camerabrand)) {
    +                $overlay->metadata->camerabrand = '???';
    +            }
    +            if (!isset($overlay->metadata->cameramodel)) {
    +                $overlay->metadata->cameramodel = '???';
    +            }
    +            if (!isset($overlay->metadata->tod)) {
    +                $overlay->metadata->tod = 'both';
    +            }
    +        }
    +    }
    +
    +    public function getOverlayList() {
    +        
    +        $overlays = [];
    +
    +        $defaultDir = $this->overlayPath . '/config/';
    +        $entries = scandir($defaultDir);
    +        foreach ($entries as $entry) {
    +            if ($entry !== '.' && $entry !== '..') {
    +                if (substr($entry,0, 7) === 'overlay') {
    +                    $templatePath = $defaultDir . $entry;
    +                    $template = file_get_contents($templatePath);
    +                    $templateData = json_decode($template);
    +                    $this->fixMetaData($templateData);
    +                    $overlays[] = [
    +                        'type' => 'Allsky',
    +                        'name' => $templateData->metadata->name,
    +                        'brand' => $templateData->metadata->camerabrand,
    +                        'model' => $templateData->metadata->cameramodel,
    +                        'tod' => $templateData->metadata->tod,
    +                        'filename' => $entry
    +                    ];
    +                }
    +            }
    +        }
    +
    +        $userDir = $this->allskyOverlays;
    +        $entries = scandir($userDir);
    +        foreach ($entries as $entry) {
    +            if ($entry !== '.' && $entry !== '..') {
    +                if (substr($entry,0, 7) === 'overlay') {
    +                    $templatePath = $userDir . $entry;
    +                    $template = file_get_contents($templatePath);
    +                    $templateData = json_decode($template);
    +                    $this->fixMetaData($templateData);
    +                    $overlays[] = [
    +                        'type' => 'User',
    +                        'name' => $templateData->metadata->name,
    +                        'brand' => $templateData->metadata->camerabrand,
    +                        'model' => $templateData->metadata->cameramodel,
    +                        'tod' => $templateData->metadata->tod,
    +                        'filename' => $entry
    +                    ];
    +                }
    +            }
    +        }
    +
    +        $data = array(
    +            'data' => $overlays
    +        );
    +        
    +        $data = json_encode($data, JSON_PRETTY_PRINT);
    +        $this->sendResponse($data);            
    +    }
    +
    +    public function getStatus() {
    +        $running = [
    +            'running' => true,
    +            'status' => 'Unknown'
    +        ];
    +        if (is_file($this->allskyStatus)) {
    +            try {
    +                $statusTxt = file_get_contents($this->allskyStatus, true);
    +                $status = json_decode($statusTxt, true);
    +                if ($status !== null) {
    +                    if (isset($status['status'])) {
    +                        $running['status'] = $status['status'];
    +                        if (strtolower($status['status']) != 'running') {
    +                            $running['running'] = false;
    +                        }
    +                    }
    +                }
    +            } catch(Exception $e) {
    +            }
    +        }
    +
    +        $this->sendResponse(json_encode($running));
    +    }
     }
     
     $overlayUtil = new OVERLAYUTIL();
    diff --git a/html/includes/raspap.php b/html/includes/raspap.php
    deleted file mode 100644
    index e6bf0289a..000000000
    --- a/html/includes/raspap.php
    +++ /dev/null
    @@ -1,17 +0,0 @@
    -<?php
    -
    -// Default admin username and password:
    -$config = array(
    -  'admin_user' => 'admin',
    -  'admin_pass' => '$2y$10$YKIyWAmnQLtiJAy6QgHQ.eCpY4m.HCEbiHaTgN6.acNC6bDElzt.i'
    -);
    -
    -// Can be overridden by what's in this file, if it exists:
    -if(file_exists(RASPI_ADMIN_DETAILS)) {
    -    if ( $auth_details = fopen(RASPI_ADMIN_DETAILS, 'r') ) {
    -      $config['admin_user'] = trim(fgets($auth_details));
    -      $config['admin_pass'] = trim(fgets($auth_details));
    -      fclose($auth_details);
    -    }
    -}
    -?>
    diff --git a/html/includes/save_file.php b/html/includes/save_file.php
    index 5c1d2908b..0c39ef058 100644
    --- a/html/includes/save_file.php
    +++ b/html/includes/save_file.php
    @@ -1,31 +1,27 @@
     <?php
     
    +// Save a file that was just modified by the user,
    +// and if needed, copy it to a local or remote Website.
    +
    +$status = null;
     include_once('functions.php');
     initialize_variables();
    -define('RASPI_ADMIN_DETAILS', RASPI_CONFIG . '/raspap.auth');
    -include_once('raspap.php');
    +
     include_once('authenticate.php');
     
    -// On success, return a string that starts with "S\t" (for Success).
    -// On failure, return a string that starts with "E\t" (for Error).
    +$debug = false;
    +$Success = "S"; $Warning = "W"; $Error = "E";
     
    -if (isset($_POST['content']))
    -	$content = $_POST['content'];
    -else
    -	$content = "";
    +// On success, return a string that starts with "S\t" (for Success)
    +// or "W\t" (for Warning);
    +// On failure, return a string that starts with "E\t" (for Error).
     
    -$path = "";
    -if (isset($_POST['path']))
    -	$path = $_POST['path'];
    -if ($path == "") {
    -	echo "E	No file name specified to save!";
    -	exit;
    +$path = getVariableOrDefault($_POST, 'path', null);
    +if ($path === null) {
    +	message_and_exit($Error, "No file name specified to save!");
     }
     
    -if (isset($_POST['isRemote']))
    -	$isRemote = $_POST['isRemote'] == "true" ? true : false;
    -else
    -	$isRemote = false;
    +$content = getVariableOrDefault($_POST, 'content', "");
     
     // "current" is a web alias to ALLSKY_HOME.
     // "website" is a web alias to ALLSKY_WEBSITE.
    @@ -37,42 +33,70 @@
     else	// website
     	$file = str_replace('website/', ALLSKY_WEBSITE . "/", $path);
     if (! file_exists($file)) {
    -	echo "E	File to save '$file' does not exist (path=$path)!";
    -	exit(1);
    +	message_and_exit($Error, "File to save '$file' does not exist (path=$path)!");
     }
     
    -$ok = true;
    +$isRemote = toBool(getVariableOrDefault($_POST, 'isRemote', "false"));
    +if ($debug) {
    +	message_and_exit($Success, "file=$file, isRemote=" . ($isRemote ? "REMOTE" : "LOCAL"));
    +}
    +
    +// The file resides locally so update it, even if it's a "remote" file
    +// which means ALSO send it to a remote site.
    +
     $msg = updateFile($file, $content, "save_file", false);
    -if ($msg == "") {
    -	if ($isRemote) {
    -		$F = ALLSKY_CONFIG . '/ftp-settings.sh';
    -		$remoteHost = get_variable($F, 'REMOTE_HOST=', '');
    -		$imageDir = get_variable($F, 'IMAGE_DIR=', '');
    -		// Remote files may have "remote_" prepended to their names; if so, set the remote
    -		// name to NOT include that string.
    -		$remoteName = str_replace("remote_", "", basename($file));
    -		$cmd = ALLSKY_SCRIPTS . "/upload.sh --silent '$file' '$imageDir' '$remoteName' 'remote_file'";
    -		exec("sudo -u " . ALLSKY_OWNER . " $cmd 2>&1", $output, $return_val);
    -		if ($return_val == 0) {
    -			$msg = "$file saved and sent to $remoteHost as $remoteName.";
    -		} else {
    -			$ok = false;
    -			$msg = implode("\n", $output);
    -			$msg = "$file saved but unable to send to $remoteHost: <pre>$msg</pre>";
    -			$msg .= "Executed $cmd";
    -		}
    +if ($msg != "") {
    +	message_and_exit($Error, "Failed to save '$file': $msg");
    +}
    +
    +$f = basename($file);		// easier to see just filename than long path
    +if (! $isRemote) {
    +	// Local file - we updated it a few lines above.
    +	message_and_exit($Success, "$f saved");
    +}
    +
    +if ($useRemoteWebsite) {
    +	// Do NOT send to remote SERVER since it doesn't have configuration files.
    +
    +	// Remote files may have "remote_" prepended to their names; if so, set the remote
    +	// name to NOT include that string.
    +	$remoteName = str_replace("remote_", "", $f);
    +	$imageDir = getVariableOrDefault($settings_array, 'remotewebsiteimagedir', "");
    +	$env_file = ALLSKY_ENV;
    +	$errorMsg = "ERROR: Unable to process env file '$env_file'.";
    +	$env_array = get_decoded_json_file($env_file, true, $errorMsg);
    +	if ($env_array === null) {
    +		$msg = "<strong>$f</strong> saved but NOT sent to remote Website; unable to read '$env_file'.";
    +		message_and_exit($Warning, $msg);
    +	}
    +	$remoteHost = getVariableOrDefault($env_array, 'REMOTEWEBSITE_HOST', null);
    +	if ($remoteHost === null) {
    +		$msg = "<strong>$f</strong> saved but NOT sent to remote Website since there isn't one defined.";
    +		message_and_exit($Warning, $msg);
    +	}
    +
    +	$U1 = ALLSKY_SCRIPTS . "/upload.sh --silent --remote-web";
    +	$U2 = "'$file' '$imageDir' '$remoteName' 'remote_file'";
    +	$cmd = "$U1 $U2";
    +	exec("sudo -u " . ALLSKY_OWNER . " $cmd 2>&1", $output, $return_val);
    +	if ($return_val == 0) {
    +		$msg = "<strong>$f</strong> saved and sent to remote Website as $remoteName.";
    +		message_and_exit($Success, $msg);
     	} else {
    -		$msg = "$file saved";
    +		$msg = "<strong>$f</strong> saved but unable to send to <strong>$remoteHost</strong>";
    +		$msg .= "<pre>$U1<br>$U2 returned<br>" . implode("<br>", $output) . "</pre>";
    +		message_and_exit($Error, $msg);
     	}
     } else {
    -	$ok = false;
    -	$msg = "Failed to save '$file': $msg";
    +	$msg = "$f saved but NOT sent to remote Website since it's not enabled.";
    +	message_and_exit($Warning, $msg);
     }
     
    -if ($ok)
    -	echo "S	";
    -else
    -	echo "E	";
    -echo $msg;
     
    -?>
    +function message_and_exit($status, $message)
    +{
    +	// Tab after status
    +	echo "${status}	$message";
    +	exit;
    +}
    +?>
    \ No newline at end of file
    diff --git a/html/includes/status_messages.php b/html/includes/status_messages.php
    index cce9f7a04..8e8f4cd5b 100644
    --- a/html/includes/status_messages.php
    +++ b/html/includes/status_messages.php
    @@ -2,7 +2,7 @@
     class StatusMessages {
     	public $messages = array();
     
    -	public function addMessage($message, $level='success', $dismissable=true) {
    +	public function addMessage($message, $level='success', $dismissable=false) {
     		$status = "<tr class='alert alert-$level'><td>$message</td>";
     		if ($dismissable) {
     			$status .= "<td class='alert-dismissable'>";
    @@ -21,32 +21,32 @@ public function addMessage($message, $level='success', $dismissable=true) {
     	// If $highlight is true, hightlight the groups of messages (often only error message(s)).
     
     	public function showMessages($clear=true, $escape=false, $highlight=false) {
    -		if ($escape === true)
    +		if ($escape === true) {
     			// We can't have any single quotes in the output.
     			$apos = "&apos;";
    -		else
    +			$nl = "";
    +			$tab = "";
    +		} else {
     			$apos = "'";
    +			$nl = "\n";
    +			$tab = "\t";
    +		}
     
     		$count = 0;
     		foreach($this->messages as $message) {
     			$count++;
     			if ($count === 1) {
     				if ($highlight) {
    -					$x .= " style=$apos" . "border: 3px dashed black; margin-top: 20px;$apos";
    +					$class = " class=${apos}highlightedBox${apos}";
     				} else {
    -					$x = "";
    -				}
    -				echo "<table width=$apos" . "100%$apos $x>";
    -				if ($highlight) {
    -					echo "<tr class=$apos alert-danger$apos style=$apos" . "height: 1em;$apos>";
    -					echo "<td colspan=$apos" . "2$apos></td>";
    -					echo "</tr>";
    +					$class = "";
     				}
    +				echo "$nl<div$class><table width=${apos}100%${apos}>";
     			}
     
     			if ($count >= 2) {
     				// space between messages
    -				echo "<tr style=$apos" . "height: 5px$apos><td></td></tr>";
    +				echo "$nl$tab<tr><td style=${apos}padding-top: 5px${apos}></td></tr>";
     			}
     
     			if ($escape === true)
    @@ -54,16 +54,12 @@ public function showMessages($clear=true, $escape=false, $highlight=false) {
     
     
     			// Replace newlines with HTML breaks.
    -			echo str_replace("\n", "<br>", $message);
    +			$message = str_replace("\n", "<br>", $message);
    +			echo "$nl$tab$message";
     		}
     
     		if ($count > 0) {
    -			if ($highlight) {
    -				echo "<tr class=$apos alert-danger$apos style=$apos" . "height: 1em;$apos>";
    -				echo "<td colspan=$apos" . "2$apos></td>";
    -				echo "</tr>";
    -			}
    -			echo "</table>";
    +			echo "$nl</table></div>$nl";
     		}
     
     		if ( $clear ) $this->messages = array();
    diff --git a/html/includes/system.php b/html/includes/system.php
    index 31039a65c..3ce65b570 100644
    --- a/html/includes/system.php
    +++ b/html/includes/system.php
    @@ -8,6 +8,19 @@
     
     function RPiVersion()
     {
    +	exec('cat /sys/firmware/devicetree/base/model', $model);
    +	$RPI = getVariableOrDefault($model, 0, null);
    +	if ($RPI !== null) {
    +		// Input example: total_mem=4096
    +		exec('sudo vcgencmd get_config total_mem | cut -d= -f2', $mem);		// in MB
    +		$mem = getVariableOrDefault($mem, 0, null);
    +		if ($mem !== null) {
    +			$mem = formatSize($mem * 1024 * 1024);
    +			$RPI = "$RPI ($mem)";
    +		}
    +		return($RPI);
    +	}
    +
     	// Lookup table from https://www.raspberrypi.org/documentation/hardware/raspberrypi/revision-codes/README.md
     	// Last updated December 2023 with Pi 5
     	$revisions = array(
    @@ -70,12 +83,7 @@ function RPiVersion()
     	if (array_key_exists($rev, $revisions)) {
     		return $revisions[$rev];
     	} else {
    -		exec('cat /proc/device-tree/model', $model);
    -		if (isset($model[0])) {
    -			return $model[0];
    -		} else {
    -			return 'Unknown Pi, rev=' . $rev;
    -		}
    +		return 'Unknown Pi, rev=' . $rev;
     	}
     }
     
    @@ -138,17 +146,17 @@ function checkNumFields($num_required, $num_have, $type, $line_num, $line, $file
     function displayProgress($x, $label, $data, $min, $current, $max, $danger, $warning, $status_override)
     {
     	if ($status_override !== "") {
    -		$status = $status_override;
    +		$myStatus = $status_override;
     	} else if ($current >= $danger) {
    -		$status = "danger";
    +		$myStatus = "danger";
     	} elseif ($current >= $warning) {
    -		$status = "warning";
    +		$myStatus = "warning";
     	} else {
    -		$status = "success";
    +		$myStatus = "success";
     	}
     	echo "<tr><td colspan='2' style='height: 5px'></td></tr>\n";
     	echo "<tr><td $x>$label</td>\n";
    -	echo "    <td style='width: 100%' class='progress'><div class='progress-bar progress-bar-$status'\n";
    +	echo "    <td style='width: 100%' class='progress'><div class='progress-bar progress-bar-$myStatus'\n";
     	echo "    role='progressbar'\n";
     
     	echo "    title='current: $current, min: $min, max: $max'";
    @@ -272,8 +280,7 @@ function displayUserData($file, $displayType)
      */
     function DisplaySystem()
     {
    -	global $status, $temptype, $page;
    -	$status = new StatusMessages();
    +	global $temptype, $page, $settings_array, $status;
     
     	$top_dir = dirname(ALLSKY_WEBSITE, 1);
     
    @@ -301,12 +308,12 @@ function DisplaySystem()
     	}
     
     	// mem used
    -	exec("free -m | awk '/Mem:/ { total=$2 } /buffers\/cache/ { used=$3 } END { print used/total*100}'", $memarray);
    +	exec("free -m | gawk '/Mem:/ { total=$2 } /buffers\/cache/ { used=$3 } END { print used/total*100}'", $memarray);
     	$memused = floor($memarray[0]);
     	// check for memused being unreasonably low, if so repeat expecting modern output of "free" command
     	if ($memused < 0.1) {
     		unset($memarray);
    -		exec("free -m | awk '/Mem:/ { total=$2 } /Mem:/ { used=$3 } END { print used/total*100}'", $memarray);
    +		exec("free -m | gawk '/Mem:/ { total=$2 } /Mem:/ { used=$3 } END { print used/total*100}'", $memarray);
     		$memused = floor($memarray[0]);
     	}
     
    @@ -401,7 +408,7 @@ function DisplaySystem()
     
     	// Optional user-specified data.
     	// TODO: read each file once and populate arrays for "data", "progress", and "button".
    -	$udf = get_variable(ALLSKY_CONFIG .'/config.sh', 'WEBUI_DATA_FILES=', '');
    +	$udf = getVariableOrDefault($settings_array, 'webuidatafiles', '');
     	if ($udf !== "") {
     		$user_data_files = explode(':', $udf);
     		$user_data_files_count = count($user_data_files);
    @@ -440,8 +447,7 @@ function DisplaySystem()
     						$e .= displayUserData($user_data_files[$i], "button-action");
     					}
     
    -					if ($status->isMessage()) 
    -						echo "<P>" . $status->showMessages() . "</p>";
    +					if ($status->isMessage()) echo "<p>" . $status->showMessages() . "</p>";
     					?>
     
     					<div class="row">
    diff --git a/html/includes/torAndVPN.php b/html/includes/torAndVPN.php
    index cced8330d..488213b3b 100644
    --- a/html/includes/torAndVPN.php
    +++ b/html/includes/torAndVPN.php
    @@ -21,11 +21,11 @@ function DisplayOpenVPNConfig() {
     	exec( 'pidof openvpn | wc -l', $openvpnstatus);
     
     	if( $openvpnstatus[0] == 0 ) {
    -		$status = '<div class="alert alert-warning alert-dismissable">OpenVPN is not running';
    +		$myStatus = '<div class="alert alert-warning alert-dismissable">OpenVPN is not running';
     	} else {
    -		$status = '<div class="alert alert-success alert-dismissable">OpenVPN is running';
    +		$myStatus = '<div class="alert alert-success alert-dismissable">OpenVPN is running';
     	}
    -	$status .= '<button type="button" class="close" data-dismiss="alert" aria-hidden="true">x</button></div>';
    +	$myStatus .= '<button type="button" class="close" data-dismiss="alert" aria-hidden="true">x</button></div>';
     
     	// parse client settings
     	foreach( $returnClient as $a ) {
    @@ -58,7 +58,7 @@ function DisplayOpenVPNConfig() {
     			<form role="form" action="?page=save_hostapd_conf" method="POST">
     			<!-- Tab panes -->
     		   	<div class="tab-content">
    -		   		<p><?php echo $status; ?></p>
    +		   		<p><?php echo $myStatus; ?></p>
     				<div class="tab-pane fade in active" id="openvpnclient">
     				<h4>Client settings</h4>
     					<div class="row">
    @@ -164,11 +164,11 @@ function DisplayTorProxyConfig(){
     	exec( 'pidof tor | wc -l', $torproxystatus);
     
     	if( $torproxystatus[0] == 0 ) {
    -		$status = '<div class="alert alert-warning alert-dismissable">TOR is not running';
    +		$myStatus = '<div class="alert alert-warning alert-dismissable">TOR is not running';
     	} else {
    -		$status = '<div class="alert alert-success alert-dismissable">TOR is running';
    +		$myStatus = '<div class="alert alert-success alert-dismissable">TOR is running';
     	}
    -	$status .= '<button type="button" class="close" data-dismiss="alert" aria-hidden="true">x</button></div>';
    +	$myStatus .= '<button type="button" class="close" data-dismiss="alert" aria-hidden="true">x</button></div>';
     
     	foreach( $return as $a ) {
     		if( $a[0] != "#" ) {
    @@ -193,7 +193,7 @@ function DisplayTorProxyConfig(){
     			<form role="form" action="?page=save_hostapd_conf" method="POST">
     			<!-- Tab panes -->
     		   	<div class="tab-content">
    -		   		<p><?php echo $status; ?></p>
    +		   		<p><?php echo $myStatus; ?></p>
     
     				<div class="tab-pane fade in active" id="basic">
     					<h4>Basic settings</h4>
    diff --git a/html/index.php b/html/index.php
    index ef0a1a383..85a988374 100644
    --- a/html/index.php
    +++ b/html/index.php
    @@ -14,7 +14,7 @@
      */
     
     // Globals
    -$lastChangedName = "lastChanged";	// json setting name
    +$lastChangedName = "lastchanged";	// json setting name
     $formReadonly = false;				// The WebUI isn't readonly
     $ME = htmlspecialchars($_SERVER["PHP_SELF"]);
     
    @@ -23,55 +23,41 @@
     include_once('includes/functions.php');
     include_once('includes/status_messages.php');
     $status = new StatusMessages();
    -$needToDisplayMessages = false;
     initialize_variables();		// sets some variables
     
     // Constants for configuration file paths.
     // These are typical for default RPi installs. Modify if needed.
    -define('RASPI_ADMIN_DETAILS', RASPI_CONFIG . '/raspap.auth');
    -define('RASPI_DNSMASQ_CONFIG', '/etc/dnsmasq.conf');
    -define('RASPI_DNSMASQ_LEASES', '/var/lib/misc/dnsmasq.leases');
    -define('RASPI_HOSTAPD_CONFIG', '/etc/hostapd/hostapd.conf');
    +include_once('includes/authenticate.php');
     define('RASPI_WPA_SUPPLICANT_CONFIG', '/etc/wpa_supplicant/wpa_supplicant.conf');
    -define('RASPI_HOSTAPD_CTRL_INTERFACE', '/var/run/hostapd');
     define('RASPI_WPA_CTRL_INTERFACE', '/var/run/wpa_supplicant');
     
     // Optional services, set to true to enable.
    +define('DHCP_ENABLED', true);
    +define('APD_ENABLED', false);
     define('RASPI_OPENVPN_ENABLED', false);
     define('RASPI_TORPROXY_ENABLED', false);
     
    -if (RASPI_OPENVPN_ENABLED) {
    -	define('RASPI_OPENVPN_CLIENT_CONFIG', '/etc/openvpn/client.conf');
    -	define('RASPI_OPENVPN_SERVER_CONFIG', '/etc/openvpn/server.conf');
    +if (DHCP_ENABLED) {
    +	define('RASPI_DNSMASQ_CONFIG', '/etc/dnsmasq.conf');
    +	define('RASPI_DNSMASQ_LEASES', '/var/lib/misc/dnsmasq.leases');
     } else {
    -	function DisplayOpenVPNConfig() {}
    +	function DisplayDHCPConfig() {}
     }
    -if (RASPI_TORPROXY_ENABLED) {
    -	define('RASPI_TORPROXY_CONFIG', '/etc/tor/torrc');
    +if (APD_ENABLED) {
    +	define('RASPI_HOSTAPD_CONFIG', '/etc/hostapd/hostapd.conf');
    +	define('RASPI_HOSTAPD_CTRL_INTERFACE', '/var/run/hostapd');
     } else {
    -	function DisplayTorProxyConfig() {}
    +	function DisplayHostAPDConfig() {}
     }
    -
    -include_once('includes/raspap.php');
    -include_once('includes/dashboard_WLAN.php');
    -include_once('includes/dashboard_LAN.php');
    -include_once('includes/liveview.php');
    -include_once('includes/authenticate.php');
    -include_once('includes/admin.php');
    -include_once('includes/dhcp.php');
    -include_once('includes/hostapd.php');
    -include_once('includes/system.php');
    -include_once('includes/configureWiFi.php');
    -include_once('includes/allskySettings.php');
    -include_once('includes/days.php');
    -include_once('includes/images.php');
    -include_once('includes/editor.php');
    -include_once('includes/overlay.php');
    -include_once('includes/module.php');
     if (RASPI_OPENVPN_ENABLED || RASPI_TORPROXY_ENABLED) {
     	include_once('includes/torAndVPN.php');
    +	define('RASPI_OPENVPN_CLIENT_CONFIG', '/etc/openvpn/client.conf');
    +	define('RASPI_OPENVPN_SERVER_CONFIG', '/etc/openvpn/server.conf');
    +	define('RASPI_TORPROXY_CONFIG', '/etc/tor/torrc');
     } else {
     	function SaveTORAndVPNConfig() {}
    +	function DisplayOpenVPNConfig() {}
    +	function DisplayTorProxyConfig() {}
     }
     
     $output = $return = 0;
    @@ -98,26 +84,27 @@ function SaveTORAndVPNConfig() {}
     	$csrf_token = $_SESSION['csrf_token'];
     }
     
    -// Get the version of the Allsky Website on the Pi, if it exists.
    -$websiteFile = ALLSKY_WEBSITE . "/version";
    -if (file_exists($websiteFile)) {
    -	$localWebsiteVersion = file_get_contents($websiteFile);
    -} else {
    -	$localWebsiteVersion = "";
    -}
    -// Ditto for a remote Allsky Website.
    +// Get the version of the remote Allsky Website, if it exists.
     $remoteWebsiteVersion = "";
    -$f = ALLSKY_WEBSITE_REMOTE_CONFIG;
    -if (file_exists($f)) {
    -	$errorMsg = "WARNING: Unable to process '$f'.";
    +if ($useRemoteWebsite) {
    +	$f = getRemoteWebsiteConfigFile(); 
    +	$errorMsg = "WARNING: ";
     	$retMsg = "";
     	$a_array = get_decoded_json_file($f, true, $errorMsg, $retMsg);
     	if ($a_array === null) {
    -		echo "$retMsg";
    +		$status->addMessage($retMsg, 'warning');
     	} else {
     		$c = getVariableOrDefault($a_array, 'config', '');
    -		if ($c !== "")
    -			$remoteWebsiteVersion = getVariableOrDefault($c, 'AllskyWebsiteVersion', '<span class="errorMsg">[unknown]</span>');
    +		if ($c !== "") {
    +			$remoteWebsiteVersion = getVariableOrDefault($c, 'AllskyVersion', null);
    +			if ($remoteWebsiteVersion === null) {
    +				$remoteWebsiteVersion = '<span class="errorMsg">[version unknown]</span>';
    +			} else if ($remoteWebsiteVersion == ALLSKY_VERSION) {
    +				$remoteWebsiteVersion = "";		// don't display if same version as Allsky
    +			} else {
    +				$remoteWebsiteVersion = "&nbsp; (version $remoteWebsiteVersion)";
    +			}
    +		}
     	}
     }
     ?>
    @@ -137,9 +124,10 @@ function SaveTORAndVPNConfig() {}
     		case "LAN_info":			$Title = "LAN Dashboard";		break;
     		case "configuration":		$Title = "Allsky Settings";		break;
     		case "wifi":				$Title = "Configure Wi-Fi";		break;
    +		case "dhcp_conf":			$Title = "Configure DHCP";		break;
    +		case "hostapd_conf":		$Title = "Configure Hotspot";	break;
     		case "openvpn_conf":		$Title = "Configure OpenVPN";	break;
     		case "torproxy_conf":		$Title = "Configure TOR proxy";	break;
    -		case "save_hostapd_conf":	$Title = "Configure Hotspot";	break;
     		case "auth_conf":			$Title = "Change password";		break;
     		case "system":				$Title = "System";				break;
     		case "list_days":			$Title = "Images";				break;
    @@ -154,6 +142,9 @@ function SaveTORAndVPNConfig() {}
     		default:					$Title = "Allsky WebUI";		break;
     	}
     ?>
    +	</script>	<!-- allows <a external="true" ...> -->
    +	<script src="documentation/js/documentation.js" type="application/javascript"></script>
    +
     	<title><?php echo "$Title - WebUI"; ?></title>
     
     	<!-- Bootstrap Core CSS -->
    @@ -162,9 +153,6 @@ function SaveTORAndVPNConfig() {}
     	<!-- MetisMenu CSS -->
     	<link href="documentation/bower_components/metisMenu/dist/metisMenu.min.css" rel="stylesheet">
     
    -	<!-- Timeline CSS -->
    -	<link href="documentation/css/timeline.css" rel="stylesheet">
    -
     	<!-- Custom CSS -->
     	<link href="documentation/css/sb-admin-2.css" rel="stylesheet">
     
    @@ -191,30 +179,6 @@ function SaveTORAndVPNConfig() {}
     	<script src="js/bigscreen.min.js"></script>
     
     	<script type="text/javascript">
    -		function getImage() {
    -			var newImg = new Image();
    -			newImg.src = '<?php echo $image_name ?>?_ts=' + new Date().getTime();
    -			newImg.id = "current";
    -			newImg.class = "current";
    -			newImg.style = "width: 100%";
    -			newImg.decode().then(() => {
    -				$("#current").attr('src', newImg.src)
    -					.attr("id", "current")
    -					.attr("class", "current")
    -					.css("width", "100%")
    -					.on('load', function () {
    -						if (!this.complete || typeof this.naturalWidth == "undefined" || this.naturalWidth == 0) {
    -							console.log('broken image!');
    -						} else {
    -							$("#live_container").empty().append(newImg);
    -						}
    -					});
    -			}).finally(() => {
    -				// Use tail recursion to trigger the next invocation after `$delay` milliseconds
    -				setTimeout(function () { getImage(); }, <?php echo $delay ?>);
    -			});
    -		}
    -
     		// Inititalize theme to light
     		if (!localStorage.getItem("theme")) {
     			localStorage.setItem("theme", "light")
    @@ -226,11 +190,10 @@ function getImage() {
     	<script src="documentation/js/sb-admin-2.js"></script>
     
     	<!-- Code Mirror editor -->
    +<?php if ($page === "editor") { ?>
     	<link rel="stylesheet" href="lib/codeMirror/codemirror.css">
     	<link rel="stylesheet" href="lib/codeMirror/monokai.min.css">
     	<script type="text/javascript" src="lib/codeMirror/codemirror.js"> </script>
    -	<script type="text/javascript" src="lib/codeMirror/shell.js"> </script>
    -<?php if ($localWebsiteVersion !== "" || $remoteWebsiteVersion !== "") { ?>
     	<script type="text/javascript" src="lib/codeMirror/json.js"> </script>
     <?php } ?>
     </head>
    @@ -239,7 +202,7 @@ function getImage() {
     	<!-- Navigation -->
     	<nav class="navbar navbar-default navbar-static-top" role="navigation" style="margin-bottom: 0">
     		<div class="navbar-header">
    -			<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
    +			<button type="button" class="navbar-toggle as-nav-toggle" data-toggle="collapse" data-target=".navbar-collapse">
     				<span class="sr-only">Toggle navigation</span>
     				<span class="icon-bar"></span>
     				<span class="icon-bar"></span>
    @@ -253,18 +216,16 @@ function getImage() {
     				<div class="version-title version-title-color">
     					<span class="nowrap">Version: <?php echo ALLSKY_VERSION; ?></span>
     					&nbsp; &nbsp;
    -<?php if ($localWebsiteVersion !== "") {
    +<?php if ($useLocalWebsite) {
     					echo "<span class='nowrap'>";
    -					echo "<a class='version-title-color' href='allsky/index.php' target='_blank' title='Click to go to local Website'>";
    -					echo "Local Website: $localWebsiteVersion";
    -					echo " <i class='fa fa-external-link-alt fa-fw'></i></a></span>";
    +					echo "<a external='true' class='version-title-color' href='allsky/index.php'>";
    +					echo "Local Website</a></span>";
     } ?>
     					&nbsp; &nbsp;
    -<?php if ($remoteWebsiteVersion !== "") {
    +<?php if ($useRemoteWebsite) {
     					echo "<span class='nowrap'>";
    -					echo "<a class='version-title-color' href='$websiteURL' target='_blank' title='Click to go to remote Website'>";
    -					echo "Remote Website: $remoteWebsiteVersion";
    -					echo " <i class='fa fa-external-link-alt fa-fw'></i></a></span>";
    +					echo "<a external='true' class='version-title-color' href='$remoteWebsiteURL'>";
    +					echo "Remote Website $remoteWebsiteVersion</a></span>";
     } ?>
     				</div>
     		</div> <!-- /.navbar-header -->
    @@ -300,6 +261,16 @@ function getImage() {
     					<li>
     						<a id="wifi" href="index.php?page=wifi"><i class="fa fa-wifi fa-fw"></i> Configure Wifi</a>
     					</li>
    +					<?php if (DHCP_ENABLED) : ?>
    +						<li>
    +							<a id="vpn" href="index.php?page=dhcp_conf"><i class="fa fa-exchange fa-fw"></i> Configure DHCP</a>
    +						</li>
    +					<?php endif; ?>
    +					<?php if (APD_ENABLED) : ?>
    +						<li>
    +							<a id="vpn" href="index.php?page=hostapd_conf"><i class="fa fa-dot-circle fa-fw"></i> Configure Hotspot</a>
    +						</li>
    +					<?php endif; ?>
     					<?php if (RASPI_OPENVPN_ENABLED) : ?>
     						<li>
     							<a id="vpn" href="index.php?page=openvpn_conf"><i class="fa fa-lock fa-fw"></i> Configure OpenVPN</a>
    @@ -317,7 +288,7 @@ function getImage() {
     						<a id="system" href="index.php?page=system"><i class="fa fa-cube fa-fw"></i> System</a>
     					</li>
     					<li>
    -						<a href="/documentation" target="_blank" title="Opens in new window"><i class="fa fa-book fa-fw"></i> Allsky Documentation <i class="fa fa-external-link-alt fa-fw"></i></a>
    +						<a external="true" href="/documentation"><i class="fa fa-book fa-fw"></i> Allsky Documentation </a>
     					</li>
     					<li>
     						<span onclick="switchTheme()"><i class="fa fa-moon fa-fw"></i> Light/Dark mode</span>
    @@ -332,11 +303,7 @@ function getImage() {
     		<div class="row right-panel">
     			<div class="col-lg-12">
     				<?php
    -				// Check if the settings are configured.
    -				check_if_configured($page, "main");
    -
    -				if ($needToDisplayMessages)
    -					$status->showMessages(true, false, true);
    +				check_if_configured($page, "main");	// It calls addMessage() on error.
     
     				if (isset($_POST['clear'])) {
     					$t = @filemtime(ALLSKY_MESSAGES);
    @@ -347,31 +314,40 @@ function getImage() {
     						if ($t == $newT) {
     							exec("sudo rm -f " . ALLSKY_MESSAGES, $result, $retcode);
     							if ($retcode !== 0) {
    -								$status->addMessage("Unable to clear messages: " . $result[0], 'danger', true);
    +								$status->addMessage("Unable to clear messages: " . $result[0], 'danger');
     								$status->showMessages();
     							}
     						} else {
    -							// If the messages changed after the user did a "clear",
    -							// and then the user refreshed the browser,
    +							// If the messages changed after the user viewed the last page
    +							// and before they clicked the "Clear" button,
     							// we'll have the old time in $filetime, but the timestamp of the file
     							// won't match so we'll get here, and then display the messages below.
    -							$status->addMessage("System Messages changed.  New content is:", "warning", false);
    +							$status->addMessage("System Messages changed.  New content is:", "warning");
     						}
     					}
     				}
    -				if (file_exists(ALLSKY_MESSAGES) && filesize(ALLSKY_MESSAGES) > 0) {
    +				clearstatcache();
    +				$size = @filesize(ALLSKY_MESSAGES);
    +				if ($size !== false && $size > 0) {
     					$contents_array = file(ALLSKY_MESSAGES, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    -					echo "<div class='row'>";
    -					echo "<div class='system-message'>";
    +					echo "<div class='row'>"; echo "<div class='system-message'>";
     						echo "<div class='title'>System Messages</div>";
     						foreach ($contents_array as $line) {
    -							// Format: level (i.e., CSS class), date, count, message
    +							// Format: level (i.e., CSS class), date, count, message [, url]
    +							//         0                        1     2      3          4
     							$message_array = explode("\t", $line);
    -							if (isset($message_array[3])) {
    +							$message = getVariableOrDefault($message_array, 3, null);
    +							if ($message !== null) {
     								$level = $message_array[0];
     								$date = $message_array[1];
     								$count = $message_array[2];
    -								$message = "<strong>" . $message_array[3] . "</strong>";
    +								$url = getVariableOrDefault($message_array, 4, "");
    +								if ($url !== "") {
    +									$m1 = "<a href='$url' title='Click for more information' target='_messages'>";
    +									$m2 = "<i class='fa fa-external-link-alt fa-fw'></i>";
    +									$m2 = "<span class='externalSmall'>$m2</span>";
    +									$message = "$m1 $message $m2</a>";
    +								}
     								if ($count == 1)
     									$message .= " &nbsp; ($date)";
     								else
    @@ -380,55 +356,73 @@ function getImage() {
     								$level = "error";	// badly formed message
     								$message = "INTERNAL ERROR: Poorly formatted message: $line";
     							}
    -							$status->addMessage($message, $level, false);
    +							$status->addMessage($message, $level);
     						}
     						$status->showMessages();
    -						echo "<div class='message-button'>";
    +						echo "<br><div class='message-button'>";
     							$ts = time();
     							echo "<form action='$ME?_ts=$ts' method='POST'>";
     							echo "<input type='hidden' name='page' value='$page'>";
     							echo "<input type='hidden' name='clear' value='true'>";
     							$t = @filemtime(ALLSKY_MESSAGES);
     							echo "<input type='hidden' name='filetime' value='$t'>";
    -							echo "<input type='submit' class='btn btn-primary' value='Clear all messages' />";
    +							echo "<input type='submit' class='btn btn-primary' value='Clear messages' />";
     							echo "</form>";
     						echo "</div>";
    -					echo "</div>";
    -					echo "</div>";
    +					echo "</div>"; echo "</div>";// /.system-message and /.row
     				}
     
     				switch ($page) {
     					case "WLAN_info":
    +						include_once('includes/dashboard_WLAN.php');
     						DisplayDashboard_WLAN();
     						break;
     					case "LAN_info":
    -						DisplayDashboard_LAN("eth0");
    +						include_once('includes/dashboard_LAN.php');
    +						DisplayDashboard_LAN();
     						break;
     					case "configuration":
    +						include_once('includes/allskySettings.php');
     						DisplayAllskyConfig();
     						break;
     					case "wifi":
    +						include_once('includes/configureWiFi.php');
     						DisplayWPAConfig();
     						break;
    +					case "dhcp_conf":
    +						include_once('includes/dhcp.php');
    +						DisplayDHCPConfig();
    +						break;
    +					case "hostapd_conf":
    +						include_once('includes/hostapd.php');
    +						DisplayHostAPDConfig();
    +						break;
     					case "openvpn_conf":
    +						include_once('includes/torAndVPN.php');
    +						DisplayTorProxyConfig();
     						DisplayOpenVPNConfig();
     						break;
     					case "torproxy_conf":
    +						include_once('includes/torAndVPN.php');
     						DisplayTorProxyConfig();
     						break;
     					case "save_hostapd_conf":
     						SaveTORAndVPNConfig();
     						break;
     					case "auth_conf":
    +						include_once('includes/admin.php');
     						DisplayAuthConfig($config['admin_user'], $config['admin_pass']);
     						break;
     					case "system":
    +						include_once('includes/system.php');
     						DisplaySystem();
     						break;
     					case "list_days":
    +						include_once('includes/days.php');
     						ListDays();
     						break;
     					case "list_images":
    +						include_once('includes/images.php');
     						ListImages();
     						break;
     					case "list_videos":
    @@ -444,18 +438,22 @@ function getImage() {
     						ListFileType("startrails/", "startrails", "Startrails", "picture");
     						break;
     					case "editor":
    +						include_once('includes/editor.php');
     						DisplayEditor();
     						break;
     					case "overlay":
    +						include_once('includes/overlay.php');
     						DisplayOverlay($image_name);
     						break;
     					case "module":
    +						include_once('includes/module.php');
     						DisplayModule();
     						break;
     
     					case "live_view":
     					default:
    -						DisplayLiveView($image_name, $delay, $daydelay, $nightdelay, $darkframe);
    +						include_once('includes/liveview.php');
    +						DisplayLiveView($image_name, $delay, $daydelay, $daydelay_postMsg, $nightdelay, $nightdelay_postMsg, $darkframe);
     				}
     				?>
     			</div>
    @@ -500,38 +498,7 @@ function addTimestamp(id) {
     	addTimestamp("wifi");
     	addTimestamp("auth_conf");
     	addTimestamp("system");
    -
    -
    -
    -<?php
    -// Only include the sticky buttons on the settings page
    -if ($page == "configuration") {
    -?>
    -	// The remaining code is to keep the "Save changes" button at the top of the "settings" page.
    -	// Get the button:
    -	let mybutton = document.getElementById("backToTopBtn");
    -
    -	if (mybutton !== null) {
    -		// When the user scrolls down 20px from the top of the document, show the button
    -		window.onscroll = function() {scrollFunction()};
    -
    -		function scrollFunction() {
    -			if (document.body.scrollTop > 20 || document.documentElement.scrollTop > 20) {
    -				mybutton.style.display = "block";
    -			} else {
    -				mybutton.style.display = "none";
    -			}
    -		}
    -
    -		// When the user clicks on the button, scroll to the top of the document
    -		function topFunction() {
    -			document.body.scrollTop = 0; // For Safari
    -			document.documentElement.scrollTop = 0; // For Chrome, Firefox, IE and Opera
    -		}
    -	}
    -<?php
    -	}
    -?>
     </script>
     </body>
     </html>
    +<script> includeHTML(); </script>
    diff --git a/html/js/jquery-overlaymanager/jquery-overlaymanager.js b/html/js/jquery-overlaymanager/jquery-overlaymanager.js
    new file mode 100644
    index 000000000..cd6d41bc5
    --- /dev/null
    +++ b/html/js/jquery-overlaymanager/jquery-overlaymanager.js
    @@ -0,0 +1,926 @@
    +"use strict";
    +
    +; (function ($) {
    +
    +    $.allskyMM = function (element, options) {
    +        var defaults = {
    +            data: {},
    +            active: true,
    +            id: 'oe-mm',
    +            dirty: false,
    +            overlaySelected: function (overlay) { }
    +        }
    +
    +        var plugin = this;
    +
    +        var element = $(element);
    +
    +        plugin.settings = $.extend({}, defaults, options);
    +        plugin.data = [];
    +        plugin.debug = false;
    +        plugin.selectedOverlay = {
    +            type: null,
    +            name: null            
    +        };
    +
    +        let pluginPrefix = plugin.settings.id + '-' + Math.random().toString(36).substr(2, 9);
    +
    +        plugin.mmId = pluginPrefix + '-allskymm';
    +        plugin.mmTrigger = pluginPrefix + '-trigger';
    +        plugin.mmWrapper = pluginPrefix + '-wrapper';
    +
    +        plugin.mmEditSelect = pluginPrefix + '-edit-select';
    +        plugin.mmDaySelect = pluginPrefix + '-day-select';
    +        plugin.mmNightSelect = pluginPrefix + '-night-select';
    +        plugin.mmTODSelect = pluginPrefix + '-tod-select';
    +
    +        plugin.mmMetaData = pluginPrefix + '-meta-data';
    +        plugin.mmFileName = pluginPrefix + '-meta-filename';
    +        plugin.mmMetaName = pluginPrefix + '-meta-name';
    +        plugin.mmMetaDescription = pluginPrefix + '-meta-description';
    +        plugin.mmMetaBrand = pluginPrefix + '-meta-brand';
    +        plugin.mmMetaModel = pluginPrefix + '-meta-model';
    +        plugin.mmMetaResolutionWidth = pluginPrefix + '-meta-resolution-width';
    +        plugin.mmMetaResolutionHeight = pluginPrefix + '-meta-resolution-height';
    +        plugin.mmMetaTod = pluginPrefix + '-meta-tod';
    +        plugin.mmConfigSave = pluginPrefix + '-config-save';
    +        plugin.mmDebug = pluginPrefix + '-debug';
    +
    +        plugin.init = function () {
    +            setupDebug();
    +            createHtml();
    +            setupMenu();
    +            setupEvents();
    +        }
    +
    +        plugin.destroy = function () {
    +            $(document).removeData('allskyMM');
    +        }
    +
    +        plugin.enabled = function() {
    +            return plugin.settings.active;
    +        }
    +
    +        plugin.show = function() {
    +            let menu = $('#' + plugin.mmWrapper);
    +            if (!menu.hasClass('active')) {
    +                menu.addClass('active');
    +            }
    +        }
    +
    +        plugin.setSelected = function(fileName) {
    +            $('#' + plugin.mmEditSelect).val(fileName);
    +            $('#' + plugin.mmEditSelect).trigger('change');
    +        }
    +
    +        plugin.showNew = function() {
    +            $('#' + plugin.mmNewDialogNew).trigger('click');
    +        }
    +
    +        plugin.hide = function() {
    +            let menu = $('#' + plugin.mmWrapper);
    +            if (menu.hasClass('active')) {
    +                menu.removeClass('active');
    +                $('.' + plugin.mmTrigger).hide();
    +            }
    +        }
    +
    +        plugin.show = function() {
    +            let menu = $('#' + plugin.mmWrapper);
    +            menu.addClass('active');
    +            $('.' + plugin.mmTrigger).show();
    +        }
    +
    +        var setupDebug = function() {
    +            let url = window.location.href;
    +            const paramArr = url.slice(url.indexOf('?') + 1).split('&');
    +            const params = {};
    +            paramArr.map(param => {
    +                const [key, val] = param.split('=');
    +                params[key] = decodeURIComponent(val);
    +            })
    +            if (params.hasOwnProperty('debug')) {
    +                if (params.debug == 'true') {
    +                    localStorage.setItem('debugMode', 'true');
    +                } else {
    +                    localStorage.setItem('debugMode', 'false');
    +                }
    +            }
    +            plugin.debug  = localStorage.getItem('debugMode') === 'true' ? true: false;          
    +        }
    +
    +        var createHtml = function() {
    +
    +            plugin.mmNewDialogNew = pluginPrefix + '-new-dialog-new';
    +            plugin.mmNewDialogDelete = pluginPrefix + '-new-dialog-delete';
    +            plugin.mmNewDialogSave = pluginPrefix + '-new-dialog-save';
    +
    +            let oeMMHTML = '\
    +                <div class="oe-mm-wrapper" id="' + plugin.mmWrapper + '">\
    +                    <nav class="navbar navbar-default">\
    +                        <div class="container-fluid">\
    +                            <div class="navbar-header">\
    +                                <div class="navbar-brand">Overlay Manager</div>\
    +                            </div>\
    +                        </div>\
    +                    </nav>\
    +                    <div class="container-fluid">\
    +                        <ul class="nav nav-tabs mt-1" role="tablist">\
    +                            <li role="presentation" class="active">\
    +                                <a href="#oe-editor-tab1"  role="tab" data-toggle="tab" id="oe-overlay-editor-tab1">Overlay</a>\
    +                            </li>\
    +                            <li role="presentation">\
    +                                <a href="#oe-exposure-tab2"  role="tab" data-toggle="tab">Activate</a>\
    +                            </li>\
    +                        </ul>\
    +                        <div class="tab-content">\
    +                            <div role="tabpanel" class="tab-pane active" id="oe-editor-tab1">\
    +                                <div class="panel panel-default">\
    +                                    <div class="panel-heading">\
    +                                        <h3 class="panel-title">Available Overlays</h3>\
    +                                    </div>\
    +                                    <div class="panel-body">\
    +                                        <div class="form-group">\
    +                                            <label for="' + plugin.mmEditSelect + '">Overlay To Edit</label>\
    +                                            <div class="row">\
    +                                                <div class="col-md-12">\
    +                                                    <select class="form-control" id="' + plugin.mmEditSelect + '" name="' + plugin.mmEditSelect + '">\
    +                                                        <option value="day">Daytime Configuration</option>\
    +                                                        <option value="night">Nighttime Configuration</option>\
    +                                                    </select>\
    +                                                </div>\
    +                                            </div>\
    +                                            <div class="row mt-3">\
    +                                                <div class="col-md-6">\
    +                                                    <button type="button" class="btn btn-primary" id="' + plugin.mmNewDialogNew + '"><i class="fa-regular fa-lg fa-square-plus"></i> Add</button>\
    +                                                </div>\
    +                                                <div class="col-md-6">\
    +                                                    <button type="button" class="btn btn-danger pull-right" id="' + plugin.mmNewDialogDelete + '"><i class="fa-solid fa-lg fa-trash-can"></i> Delete</button>\
    +                                                </div>\
    +                                            </div>\
    +                                        </div>\
    +                                    </div>\
    +                                </div>\
    +                                <div class="panel panel-default">\
    +                                    <div class="panel-heading">\
    +                                        <h3 class="panel-title">Overlay Meta Data</h3>\
    +                                    </div>\
    +                                    <div class="panel-body">\
    +                                        <div class="row">\
    +                                            <div class="col-md-12 col-sm-12 col-xs-12">\
    +                                                <div class="form-group ' + plugin.mmDebug + ' hidden">\
    +                                                    <label class="control-label requiredField" for="' + plugin.mmFileName + '">Filename\
    +                                                        <span class="asteriskField">*</span>\
    +                                                    </label>\
    +                                                    <input class="form-control ' + plugin.mmMetaData  + '" id="' + plugin.mmFileName + '" name="' + plugin.mmFileName + '" type="text" data-field="filename" />\
    +                                                </div>\
    +                                            </div>\
    +                                        </div>\
    +                                        <div class="row">\
    +                                            <div class="col-md-12 col-sm-12 col-xs-12">\
    +                                                <div class="form-group ">\
    +                                                    <label class="control-label requiredField" for="' + plugin.mmMetaName + '">Name\
    +                                                        <span class="asteriskField">*</span>\
    +                                                    </label>\
    +                                                    <input class="form-control ' + plugin.mmMetaData  + '" id="' + plugin.mmMetaName + '" name="' + plugin.mmMetaName + '" type="text" data-field="name" />\
    +                                                </div>\
    +                                            </div>\
    +                                        </div>\
    +                                        <div class="row">\
    +                                            <div class="col-md-12 col-sm-12 col-xs-12">\
    +                                                <div class="form-group ">\
    +                                                    <label class="control-label " for="' + plugin.mmMetaDescription + '">Description</label>\
    +                                                    <textarea class="form-control ' + plugin.mmMetaData  + '" cols="40" id="' + plugin.mmMetaDescription + '" name="' + plugin.mmMetaDescription + '" rows="2" data-field="description"></textarea>\
    +                                                </div>\
    +                                            </div>\
    +                                        </div>\
    +                                        <div class="row">\
    +                                            <div class="col-md-6">\
    +                                                <div class="form-group ">\
    +                                                    <label class="control-label " for="' + plugin.mmMetaBrand + '">Camera Brand</label>\
    +                                                    <select class="select form-control ' + plugin.mmMetaData + '" id="' + plugin.mmMetaBrand + '" name="' + plugin.mmMetaBrand + '" data-field="camerabrand" >\
    +                                                    </select>\
    +                                                </div>\
    +                                            </div>\
    +                                            <div class="col-md-6">\
    +                                                <div class="form-group ">\
    +                                                    <label class="control-label " for="' + plugin.mmMetaModel + '">Camera Model</label>\
    +                                                    <input class="form-control ' + plugin.mmMetaData  + '" id="' + plugin.mmMetaModel + '" name="' + plugin.mmMetaModel + '" placeholder="Enter the camera model i.e. 178MM" type="text" data-field="cameramodel" />\
    +                                                </div>\
    +                                            </div>\
    +                                        </div>\
    +                                        <div class="row">\
    +                                            <div class="col-md-6">\
    +                                                <div class="form-group ">\
    +                                                    <label class="control-label " for="' + plugin.mmMetaResolutionWidth + '">Width</label>\
    +                                                    <input class="form-control ' + plugin.mmMetaData  + '" id="' + plugin.mmMetaResolutionWidth + '" name="' + plugin.mmMetaResolutionWidth + '" placeholder="width" type="number" data-field="cameraresolutionwidth" />\
    +                                                </div>\
    +                                            </div>\
    +                                            <div class="col-md-6">\
    +                                                <div class="form-group ">\
    +                                                    <label class="control-label " for="' + plugin.mmMetaResolutionHeight + '">Height</label>\
    +                                                    <input class="form-control ' + plugin.mmMetaData  + '" id="' + plugin.mmMetaResolutionHeight + '" name="' + plugin.mmMetaResolutionHeight + '" placeholder="height" type="number" data-field="cameraresolutionheight" />\
    +                                                </div>\
    +                                            </div>\
    +                                        </div>\
    +                                        <div class="row">\
    +                                            <div class="col-md-12 col-sm-12 col-xs-12">\
    +                                                <div class="form-group ">\
    +                                                    <label class="control-label " for="' + plugin.mmMetaTod + '">Available For</label>\
    +                                                    <select class="form-control ' + plugin.mmMetaData  + '" id="' + plugin.mmMetaTod + '" name="' + plugin.mmMetaTod + '" data-field="tod">\
    +                                                        <option value="both">Day and Night</option>\
    +                                                        <option value="day">Daytime</option>\
    +                                                        <option value="night">Nighttime</option>\
    +                                                    </select>\
    +                                                </div>\
    +                                            </div>\
    +                                        </div>\
    +                                    </div>\
    +                                </div>\
    +                            </div>\
    +                            <div role="tabpanel" class="tab-pane" id="oe-exposure-tab2">\
    +                                <div class="panel panel-default">\
    +                                    <div class="panel-heading">\
    +                                        <h3 class="panel-title">Active Overlays</h3>\
    +                                    </div>\
    +                                    <div class="panel-body">\
    +                                        <div class="row mt-2">\
    +                                            <div class="col-md-12 col-sm-12 col-xs-12">\
    +                                                <div class="form-group ">\
    +                                                    <label class="control-label " for="' + plugin.mmDaySelect + '">Daytime</label>\
    +                                                    <select class="select form-control ' + plugin.mmTODSelect + '" id="' + plugin.mmDaySelect + '" name="' + plugin.mmDaySelect + '"></select>\
    +                                                </div>\
    +                                                <div class="form-group ">\
    +                                                    <label class="control-label " for="' + plugin.mmNightSelect + '">Nighttime</label>\
    +                                                    <select class="select form-control ' + plugin.mmTODSelect + '" id="' + plugin.mmNightSelect + '" name="' + plugin.mmNightSelect + '"></select>\
    +                                                </div>\
    +                                            </div>\
    +                                        </div>\
    +                                        <div class="row mt-2">\
    +                                            <div class="col-md-12 col-sm-12 col-xs-12">\
    +                                                <button type="button" class="btn btn-primary pull-right" id="' + plugin.mmConfigSave + '">Save</button>\
    +                                            </div>\
    +                                        </div>\
    +                                    </div>\
    +                                </div>\
    +                            </div>\
    +                            <button type="button" class="btn btn-primary pull-right ' + plugin.mmTrigger + '">Close</button>\
    +                        </div>\
    +                    </div>\
    +                </div>\
    +                <button class="oe-mm-trigger fa fa-xmark ' + plugin.mmTrigger + '">\
    +                </button>';
    +            
    +            plugin.mmNewDialog = pluginPrefix + "-new-dialog";
    +            plugin.mmNewDialogSave = pluginPrefix + "-new-dialog-save";
    +            plugin.mmNewDialogCancel = pluginPrefix + "-new-dialog-cancel";
    +
    +            plugin.mmNewDialogForm = pluginPrefix + "-new-dialog-form";
    +
    +            plugin.mmNewDialogField = pluginPrefix + "-new-dialog-field";
    +            plugin.mmNewDialogCopy = pluginPrefix + "-new-dialog-copy";
    +            plugin.mmNewDialogFileName = pluginPrefix + "-new-dialog-filename";
    +            plugin.mmNewDialogName = pluginPrefix + "-new-dialog-name";
    +            plugin.mmNewDialogDescription = pluginPrefix + "-new-dialog-description";
    +            plugin.mmNewDialogBrand = pluginPrefix + "-new-dialog-brand";
    +            plugin.mmNewDialogModel = pluginPrefix + "-new-dialog-model";
    +            plugin.mmNewDialogResolutionWidth = pluginPrefix + "-new-dialog-resolutionwidth";
    +            plugin.mmNewDialogResolutionHeight = pluginPrefix + "-new-dialog-resolutionheight";
    +            plugin.mmNewDialogTod = pluginPrefix + "-new-dialog-tod";
    +            plugin.mmNewDialogActivate = pluginPrefix + "-new-dialog-activate";
    +            plugin.mmNewDialogSuggest = pluginPrefix + "-new-dialog-suggest";
    +            plugin.mmNewDialogAdvancedField = pluginPrefix + "-new-dialog-advanced";
    +
    +            let dialogHTML = '\
    +                <div class="modal" role="dialog" id="' + plugin.mmNewDialog + '">\
    +                    <div class="modal-dialog" role="document">\
    +                        <div class="modal-content">\
    +                            <div class="modal-header">\
    +                                <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>\
    +                                <h4 class="modal-title">Create New Overlay</h4>\
    +                            </div>\
    +                            <div class="modal-body">\
    +                                <div class="row">\
    +                                    <div class="col-md-12 col-sm-12 col-xs-12">\
    +                                        <form id="' + plugin.mmNewDialogForm + '">\
    +                                            <div class="panel panel-default">\
    +                                                <div class="panel-heading">\
    +                                                    <h3 class="panel-title">Select Template</h3>\
    +                                                </div>\
    +                                                <div class="panel-body">\
    +                                                    <div>\
    +                                                        <p>Select the template you wish to base your new overlay on. If you wish to create a blank overlay select \'Blank Overlay\'</p>\
    +                                                    </div>\
    +                                                    <div class="form-group ">\
    +                                                        <select class="select form-control" id="' + plugin.mmNewDialogCopy + '" name="' + plugin.mmNewDialogCopy + '" title="Select the template you wish to base your new overlay on"></select>\
    +                                                    </div>\
    +                                                </div>\
    +                                            </div>\
    +                                            <div class="panel panel-default">\
    +                                                <div class="panel-heading">\
    +                                                    <h3 class="panel-title">Overlay Meta Data</h3>\
    +                                                </div>\
    +                                                <div class="panel-body">\
    +                                                    <ul class="nav nav-tabs" role="tablist">\
    +                                                        <li role="presentation" class="active"><a href="#oe-item-list-dialog-allsky1" role="tab" data-toggle="tab">Basic</a></li>\
    +                                                        <li role="presentation"><a href="#oe-item-list-dialog-all1" aria-controls="profile" role="tab" data-toggle="tab">Advanced</a></li>\
    +                                                    </ul>\
    +                                                    <div class="tab-content">\
    +                                                        <div role="tabpanel" class="tab-pane active mt-2" id="oe-item-list-dialog-allsky1">\
    +                                                            <div class="row">\
    +                                                                <div class="col-md-12">\
    +                                                                    <div class="form-group ">\
    +                                                                        <label class="control-label requiredField" for="' + plugin.mmNewDialogName + '">Name <span class="asteriskField">*</span></label>\
    +                                                                        <input class="form-control ' + plugin.mmNewDialogField + '" id="' + plugin.mmNewDialogName + '" name="' + plugin.mmNewDialogName + '" type="text"/>\
    +                                                                        <span class="help-block text-danger hidden" id="' + plugin.mmNewDialogName + '-error">Please enter a name for this overlay</span>\
    +                                                                    </div>\
    +                                                                </div>\
    +                                                            </div>\
    +                                                            <div class="row">\
    +                                                                <div class="col-md-12">\
    +                                                                    <div class="form-group ">\
    +                                                                        <label class="control-label " for="' + plugin.mmNewDialogDescription + '">Description</label>\
    +                                                                        <textarea class="form-control ' + plugin.mmNewDialogField + '" cols="40" id="' + plugin.mmNewDialogDescription + '" name="' + plugin.mmNewDialogDescription + '" rows="2"></textarea>\
    +                                                                    </div>\
    +                                                                </div>\
    +                                                            </div>\
    +                                                            <div class="row">\
    +                                                                <div class="col-md-5">\
    +                                                                    <div class="form-group ">\
    +                                                                        <label class="control-label " for="' + plugin.mmNewDialogTod + '">Available For</label>\
    +                                                                        <select class="select form-control ' + plugin.mmNewDialogAdvancedField + '" id="' + plugin.mmNewDialogTod + '" name="' + plugin.mmNewDialogTod + '">\
    +                                                                            <option value="both">Day and Night</option>\
    +                                                                            <option value="day">Daytime</option>\
    +                                                                            <option value="night">Nightime</option>\
    +                                                                        </select>\
    +                                                                    </div>\
    +                                                                </div>\
    +                                                                <div class="col-md-7">\
    +                                                                    <div class="form-group form-check">\
    +                                                                        <label class="control-label " for="' + plugin.mmNewDialogActivate + '-yes">Activate After Creation</label><br>\
    +                                                                        <div class="switch-field boxShadow settingInput settingInputBoolean">\
    +                                                                            <input id="' + plugin.mmNewDialogActivate + '-no" class="form-control" type="radio" name="' + plugin.mmNewDialogActivate + '" value="false">\
    +                                                                            <label style="margin-bottom: 0px;" for="' + plugin.mmNewDialogActivate + '-no">No</label>\
    +                                                                            <input id="' + plugin.mmNewDialogActivate + '-yes" class="form-control" type="radio" name="' + plugin.mmNewDialogActivate + '" value="true" checked="">\
    +                                                                            <label style="margin-bottom: 0px;" for="' + plugin.mmNewDialogActivate + '-yes">Yes</label>\
    +                                                                        </div>\
    +                                                                    </div>\
    +                                                                </div>\
    +                                                            </div>\
    +                                                        </div>\
    +                                                        <div role="tabpanel" class="tab-pane mt-2" id="oe-item-list-dialog-all1">\
    +                                                            <div class="row">\
    +                                                                <div class="col-md-12">\
    +                                                                    <div class="form-group ">\
    +                                                                        <label class="control-label requiredField" for="' + plugin.mmNewDialogFileName + '">Filename <span class="asteriskField">*</span></label>\
    +                                                                        <input class="form-control ' + plugin.mmNewDialogField + '" id="' + plugin.mmNewDialogFileName + '" name="' + plugin.mmNewDialogFileName + '" type="text"/>\
    +                                                                        <span class="help-block text-danger hidden" id="' + plugin.mmNewDialogFileName + '-error">Please enter a unique filename. The name you entered is already in use</span>\
    +                                                                    </div>\
    +                                                                </div>\
    +                                                            </div>\
    +                                                            <div class="row">\
    +                                                                <div class="col-md-6">\
    +                                                                    <div class="form-group ">\
    +                                                                        <label class="control-label requiredField" for="' + plugin.mmNewDialogBrand + '">Camera Brand</label>\
    +                                                                        <select class="select form-control ' + plugin.mmNewDialogField + ' ' + plugin.mmNewDialogAdvancedField + '" id="' + plugin.mmNewDialogBrand + '" name="' + plugin.mmNewDialogBrand + '" >\
    +                                                                        </select>\
    +                                                                    </div>\
    +                                                                </div>\
    +                                                                <div class="col-md-6">\
    +                                                                    <div class="form-group ">\
    +                                                                        <label class="control-label requiredField" for="' + plugin.mmNewDialogModel + '">Camera Model</label>\
    +                                                                        <input class="form-control ' + plugin.mmNewDialogField + ' ' + plugin.mmNewDialogAdvancedField + '" id="' + plugin.mmNewDialogModel + '" name="" type="text"/>\
    +                                                                    </div>\
    +                                                                </div>\
    +                                                            </div>\
    +                                                            <div class="row">\
    +                                                                <div class="col-md-6">\
    +                                                                    <div class="form-group ">\
    +                                                                        <label class="control-label " for="' + plugin.mmNewDialogResolutionWidth + '">Width</label>\
    +                                                                        <input class="form-control ' + plugin.mmNewDialogField + ' ' + plugin.mmNewDialogAdvancedField + '" id="' + plugin.mmNewDialogResolutionWidth + '" name="' + plugin.mmNewDialogResolutionWidth + '" type="number"/>\
    +                                                                    </div>\
    +                                                                </div>\
    +                                                                <div class="col-md-6">\
    +                                                                    <div class="form-group ">\
    +                                                                        <label class="control-label " for="' + plugin.mmNewDialogResolutionHeight + '">Height</label>\
    +                                                                        <input class="form-control ' + plugin.mmNewDialogField + ' ' + plugin.mmNewDialogAdvancedField + '" id="' + plugin.mmNewDialogResolutionHeight + '" name="' + plugin.mmNewDialogResolutionHeight + '" type="number"/>\
    +                                                                    </div>\
    +                                                                </div>\
    +                                                            </div>\
    +                                                        </div>\
    +                                                    </div>\
    +                                                </div>\
    +                                            </div>\
    +                                        </form>\
    +                                    </div>\
    +                                </div>\
    +                            </div>\
    +                            <div class="modal-footer">\
    +                                <button type="button" class="btn btn-default" id="' + plugin.mmNewDialogCancel + '" data-dismiss="modal">Cancel</button>\
    +                                <button type="button" class="btn btn-primary" id="' + plugin.mmNewDialogSave + '">Save</button>\
    +                            </div>\
    +                        </div>\
    +                    </div>\
    +                </div>\
    +            ';
    +
    +            element.empty();
    +            element.append(oeMMHTML + dialogHTML);
    +        }
    +
    +        var setupMenu = function() {
    +            $('.' + plugin.mmTrigger).hide();
    +            let menu = $('#' + plugin.mmWrapper);
    +            $(document).on('click', '.' + plugin.mmTrigger, (e) => {
    +                    menu.removeClass('active');
    +                    $('.' + plugin.mmTrigger).hide();
    +            });
    +
    +            $(document).on('click', (e) => {
    +                if ($(e.target).is('canvas') || $(e.target).is('#oe-overlay-disable')) {
    +                    menu.removeClass('active');
    +                    $('.' + plugin.mmTrigger).hide();
    +                }
    +            });              
    +        }
    +
    +        var show = function() {
    +            let menu = $('#' + plugin.mmWrapper);
    +            if (!menu.hasClass('active')) {
    +                menu.addClass('active');
    +                $('.' + plugin.mmTrigger).show();
    +            }
    +        }
    +
    +        var setupEvents = function() {
    +
    +            $(document).on('oe-show-overlay-manager', (e) => {
    +                show();
    +            });
    +
    +            $(document).on('oe-overlay-saved', (e,data) => {
    +                let configManager = window.oedi.get('config');
    +                configManager.loadOverlays();
    +                buildUI();
    +            });
    +
    +            $(document).on('oe-overlay-loaded', (e,data) => {
    +                plugin.selectedOverlay = data.overlay
    +                buildUI();
    +            });
    +
    +            $(document).on('click', '#oe-overlay-disable-new', (event) => {
    +                event.preventDefault();
    +                event.stopPropagation();
    +                plugin.show();
    +                plugin.showNew();
    +                let configManager = window.oedi.get('config');
    +                let overlays = configManager.overlays;
    +//                $('#' + plugin.mmNewDialogCopy).val(plugin.selectedOverlay.name);
    +                $('#' + plugin.mmNewDialogCopy).val(overlays.current);
    +            });
    +
    +            $(document).on('oe-startup', (e,data) => {
    +                let configManager = window.oedi.get('config');
    +                let overlays = configManager.overlays;
    +
    +                let type = 'allsky';
    +                for (let overlay in overlays.useroverlays) {
    +                    if (overlay == overlays.current) {
    +                        type = 'user';
    +                        break;
    +                    }
    +                }
    +                configManager.loadOverlay(overlays.current, type);
    +                                
    +            });
    +            
    +            $(document).on('change', '#' + plugin.mmEditSelect, (e) => {
    +                let overlay = $(e.target).val();
    +                let uiManager = window.oedi.get('uimanager');
    +
    +                if (uiManager.dirty) {
    +                    bootbox.confirm('Are you sure you wish to load a new overlay. You will lose any unsaved changes', (result) => {
    +                        if (result) {
    +                            let selectOption = $('#' + plugin.mmEditSelect).find(':selected');
    +                            if (selectOption.data('type') === 'allsky') {
    +                                $('#' + plugin.mmNewDialogDelete).addClass('disabled');
    +                            } else {
    +                                $('#' + plugin.mmNewDialogDelete).removeClass('disabled');
    +                            }                       
    +                            window.oedi.get('config').loadOverlay(overlay, selectOption.data('type'));
    +                        } else {                          
    +                            $('#' + plugin.mmEditSelect).val(plugin.selectedOverlay.name);
    +                        }
    +                    });                    
    +                } else {
    +                    let selectOption = $('#' + plugin.mmEditSelect).find(':selected');
    +                    if (selectOption.data('type') === 'allsky') {
    +                        $('#' + plugin.mmNewDialogDelete).addClass('disabled');
    +                    } else {
    +                        $('#' + plugin.mmNewDialogDelete).removeClass('disabled');
    +                    }                       
    +                    window.oedi.get('config').loadOverlay(overlay, selectOption.data('type'));
    +                }                                    
    +                
    +            });
    +
    +            $(document).on('change','.' + plugin.mmMetaData, (e) => {
    +                let field = $(e.target).data('field');
    +                if (field !== undefined) {
    +                    let configManager = window.oedi.get('config');
    +                    let value = $(e.target).val();
    +                    configManager.setMetaField(field, value);
    +                    $(document).trigger('oe-config-updated');
    +                }
    +            });
    +
    +            $(document).on('click', '#' + plugin.mmNewDialogNew, (e) => {
    +                let uiManager = window.oedi.get('uimanager');
    +
    +                if (uiManager.dirty) {
    +                    bootbox.confirm('Are you sure you wish to create a new overlay. You will lose any unsaved changes', (result) => {
    +                        if (result) {
    +                            showNewOverlayDialog();
    +                            $('#' + plugin.mmNewDialogCopy).val($('#' + plugin.mmEditSelect).val());
    +                        }
    +                    });                    
    +                } else {
    +                    showNewOverlayDialog();
    +                    $('#' + plugin.mmNewDialogCopy).val($('#' + plugin.mmEditSelect).val());
    +                }
    +            });
    +
    +            $(document).on('focusout', '.' + plugin.mmNewDialogField, function(e){
    +                $(this).removeClass('has-error');
    +                $(this).parent().parent().next('.help-block').addClass('hidden');
    +                $(this).next('.help-block').addClass('hidden'); // YUK !
    +            });
    +
    +            $(document).on('click', '#' + plugin.mmNewDialogSave, (e) => {
    +                let hasErrors = false;
    +
    +                let fileName = $('#' + plugin.mmNewDialogFileName).val();
    +                fileName = fileName.replace(/\s+/g,'');
    +    
    +                if (fileName !== '') {
    +                    let result = $.ajax({
    +                        type: "GET",
    +                        url: "includes/overlayutil.php?request=ValidateFilename&filename=" + fileName,
    +                        data: "",
    +                        dataType: 'json',
    +                        cache: false,
    +                        async: false
    +                    });
    +    
    +                    if (result.responseJSON !== undefined) {
    +                        if (result.responseJSON.error === true) {
    +                            hasErrors = true;
    +                        }
    +                    }
    +                } else {
    +                    hasErrors = true;
    +                }
    +    
    +                if (hasErrors) {
    +                    $('#' + plugin.mmNewDialogFileName).addClass('has-error');
    +                    $('#' + plugin.mmNewDialogFileName).parent().parent().next('.help-block').removeClass('hidden');
    +                    hasErrors = true;
    +                }
    +    
    +                let name = $('#' + plugin.mmNewDialogName).val();
    +                name = name.replace(/\s+/g,'');
    +                if (name === '') {
    +                    $('#' + plugin.mmNewDialogName).addClass('has-error');
    +                    $('#' + plugin.mmNewDialogName).next('.help-block').removeClass('hidden');
    +                    hasErrors = true;
    +                }
    +    
    +                if (!hasErrors) {
    +                    let formData = {
    +                        'data': {
    +                            'copy': $('#' + plugin.mmNewDialogCopy).val(),
    +                            'filename': $('#' + plugin.mmNewDialogFileName).val()    
    +                        },
    +                        'fields': {
    +                            'name': $('#' + plugin.mmNewDialogName).val(),
    +                            'description': $('#' + plugin.mmNewDialogDescription).val(),
    +                            'camerabrand': $('#' + plugin.mmNewDialogBrand).val(),
    +                            'cameramodel': $('#' + plugin.mmNewDialogModel).val(),
    +                            'cameraresolutionwidth': $('#' + plugin.mmNewDialogResolutionWidth).val(),
    +                            'cameraresolutionheight': $('#' + plugin.mmNewDialogResolutionHeight).val(),
    +                            'tod': $('#' + plugin.mmNewDialogTod).val()
    +                        }
    +                    };
    +
    +                    let result = $.ajax({
    +                        type: 'POST',
    +                        url: 'includes/overlayutil.php?request=NewOverlay',
    +                        data: formData,
    +                        dataType: 'json',
    +                        cache: false,
    +                        async: false
    +                    });
    +
    +                    let activate = $('input[name=' + plugin.mmNewDialogActivate + ']:checked').val();
    +                    activate = (activate?.toLowerCase?.() === 'true');
    +                    if (activate) {
    +                        let tod = $('#' + plugin.mmNewDialogTod).val();
    +                        let day = $('#' + plugin.mmDaySelect).val();
    +                        let night = $('#' + plugin.mmNightSelect).val();
    +                        if (tod === 'day' || tod === 'both') {
    +                            day = formData.data.filename + '.json';
    +                        }
    +                        if (tod === 'night' || tod === 'both') {
    +                            night = formData.data.filename + '.json';                            
    +                        }
    +                        saveSettings(day, night);
    +                    }
    +                    
    +                    let configManager = window.oedi.get('config');
    +                    configManager.loadOverlays();
    +                    let selected = $('#' + plugin.mmNewDialogFileName).val() + '.json';
    +                    configManager.loadOverlay(selected, 'user');
    +                    buildUI();
    +                    $('#' + plugin.mmNewDialog).modal('hide');
    +                    $('#' + plugin.mmNewDialogDelete).removeClass('disabled');
    +
    +                }                
    +            });
    +
    +            $(document).on('click', '#' + plugin.mmNewDialogDelete, (e) => {
    +                let selectedValue = $('#' + plugin.mmEditSelect).val();
    +                let selectedDayValue = $('#' + plugin.mmDaySelect).val();
    +                let selectedNightValue = $('#' + plugin.mmNightSelect).val();
    +
    +                if (selectedValue === selectedDayValue || selectedValue === selectedNightValue) {
    +                    bootbox.alert('This overlay is currently being used for day or night overlays. Please select another overlay for day or night before deleting it');
    +                } else {
    +                    bootbox.confirm('Are you sure you wish to delete this overlay? This CANNOT be undone', (result) => {
    +                        if (result) {
    +                            let result = $.ajax({
    +                                type: 'GET',
    +                                url: 'includes/overlayutil.php?request=DeleteOverlay&filename=' + selectedValue,
    +                                dataType: 'json',
    +                                cache: false,
    +                                async: false
    +                            });
    +                            let configManager = window.oedi.get('config');
    +                            configManager.loadOverlays();
    +                            buildUI();                                                      
    +                        }
    +                    });     
    +                }             
    +            });
    +
    +            $('#' + plugin.mmNewDialog).on('hidden.bs.modal', (e) => {
    +                destroyDialog();
    +            });
    +
    +            $(document).on('change', '.' + plugin.mmTODSelect, (e) => {
    +                $('#' + plugin.mmConfigSave).removeClass('disabled');
    +            });
    +
    +            $(document).on('click', '#' + plugin.mmConfigSave, (e) => {
    +                saveSettings($('#' + plugin.mmDaySelect).val(), $('#' + plugin.mmNightSelect).val());
    +                $('#' + plugin.mmConfigSave).addClass('disabled');           
    +            });
    +
    +            $(document).on('change', '.' + plugin.mmNewDialogAdvancedField, (e) => {
    +                updateNewFilename();
    +            });
    +
    +        }
    +
    +        var saveSettings = function(day, night) {
    +            let result = $.ajax({
    +                type: 'POST',
    +                url: 'includes/overlayutil.php?request=SaveSettings',
    +                data: {
    +                    daytime: day,
    +                    nighttime: night
    +                },
    +                dataType: 'json',
    +                cache: false,
    +                async: false
    +            });
    +
    +            return result;
    +        }
    +
    +        var destroyDialog = function() {
    +            $('#' + plugin.mmNewDialogCopy).val('none');
    +            $('#' + plugin.mmNewDialogFileName).val('');
    +            $('#' + plugin.mmNewDialogName).val('');
    +            $('#' + plugin.mmNewDialogDescription).val('');
    +            $('#' + plugin.mmNewDialogBrand).val('');
    +            $('#' + plugin.mmNewDialogModel).val('');
    +            $('#' + plugin.mmNewDialogResolutionwidth).val('');
    +            $('#' + plugin.mmNewDialogResolutionHeight).val('');
    +            $('#' + plugin.mmNewDialogTod).val('both');
    +            $('#' + plugin.mmNewDialog).data('bs.modal', null);
    +        }
    +
    +        var updateNewFilename = function() {
    +            let template = 'overlay{num}-{brand}_{model}-{width}x{height}-{tod}';
    +
    +            let brand = $('#' + plugin.mmNewDialogBrand).val();
    +            let fileName = template.replace('{brand}', brand);
    +
    +            let model = $('#' + plugin.mmNewDialogModel).val().trim();
    +            model = model.replace(/ /g, '_');
    +            fileName = fileName.replace('{model}', model);
    +
    +            let width = $('#' + plugin.mmNewDialogResolutionWidth).val();
    +            fileName = fileName.replace('{width}', width);
    +            
    +            let height = $('#' + plugin.mmNewDialogResolutionHeight).val();
    +            fileName = fileName.replace('{height}', height);
    +
    +            let tod = $('#' + plugin.mmNewDialogTod).val();
    +            fileName = fileName.replace('{tod}', tod);
    +
    +            let result = $.ajax({
    +                type: "GET",
    +                url: "includes/overlayutil.php?request=Suggest",
    +                data: {
    +                    template: template,
    +                    filename: fileName,
    +                    brand: brand,
    +                    model: model,
    +                    width: width,
    +                    height: height
    +                },
    +                dataType: 'json',
    +                cache: false,
    +                async: false,
    +                context: this
    +            });
    +
    +            let num = result.responseJSON;
    +            fileName = fileName.replace('{num}', num);
    +
    +            $('#' + plugin.mmNewDialogFileName).val(fileName);
    +        }
    +
    +        var showNewOverlayDialog = function() {
    +            $('#' + plugin.mmNewDialogCopy).empty().append($('<option>').val('none').text('Blank Overlay'));
    +            let configManager = window.oedi.get('config');
    +            let data = configManager.overlays;
    +            for (let overlay in data.coreoverlays) {
    +                let name = data.coreoverlays[overlay].metadata.name
    +                if (overlay === plugin.selectedOverlay.name) {
    +                    name += ' Default';
    +                }
    +                $('#' + plugin.mmNewDialogCopy).append($('<option>').val(overlay).text('Allsky - ' + name));
    +            }
    +            for (let overlay in data.useroverlays) {
    +                let name = data.useroverlays[overlay].metadata.name
    +                $('#' + plugin.mmNewDialogCopy).append($('<option>').val(overlay).text('User - ' + name));
    +            }
    +
    +            $('#' + plugin.mmNewDialogBrand).empty();
    +            for (let brand in data.brands) {
    +                $('#' + plugin.mmNewDialogBrand).append($('<option>').val(data.brands[brand]).text(data.brands[brand]));
    +            }
    +
    +            $('#' + plugin.mmNewDialogBrand).val($('#' + plugin.mmMetaBrand).val());
    +            $('#' + plugin.mmNewDialogModel).val($('#' + plugin.mmMetaModel).val());
    +
    +            let image = $('#oe-background-image');
    +            $('#' + plugin.mmNewDialogResolutionWidth).val(image[0].width|0);
    +            $('#' + plugin.mmNewDialogResolutionHeight).val(image[0].height|0);
    +
    +            $('#' + plugin.mmWrapper).removeClass('active');
    +            $('.' + plugin.mmTrigger).hide();
    +
    +            $('#' + plugin.mmNewDialog).modal({
    +                keyboard: false,
    +                width: 800
    +            });
    +
    +            $('#' + plugin.mmNewDialog).on('hidden.bs.modal', function (e) {
    +                $('#' + plugin.mmWrapper).addClass('active');
    +                $('.' + plugin.mmTrigger).show();
    +            })
    +                          
    +            updateNewFilename();
    +        }
    +
    +        var buildUI = function () {
    +            let configManager = window.oedi.get('config');
    +            let data = configManager.overlays;
    +            resetSelect(plugin.mmEditSelect, 'both', true, (plugin.selectedOverlay.name !== null) ? plugin.selectedOverlay.name : null);
    +            resetSelect(plugin.mmDaySelect, 'day', true, (data.config.daytime !== null) ? data.config.daytime : null);
    +            resetSelect(plugin.mmNightSelect, 'night', true, (data.config.nighttime !== null) ? data.config.nighttime : null);
    +
    +            resetSelect(plugin.mmMetaData, (plugin.selectedOverlay.name !== null) ? plugin.selectedOverlay.name : null);
    +            
    +            $('#' + plugin.mmMetaBrand).empty();
    +            for (let brand in data.brands) {
    +                $('#' + plugin.mmMetaBrand).append($('<option>').val(data.brands[brand]).text(data.brands[brand]));
    +            }
    +            $(plugin.mmMetaBrand).val(configManager.getMetaField('camerabrand'));
    +
    +            if (plugin.selectedOverlay.type === 'allsky' && !plugin.debug ) {
    +                $('.' + plugin.mmMetaData).prop('disabled', true);
    +            } else {
    +                $('.' + plugin.mmMetaData).prop('disabled', false);
    +            }
    +
    +            if (plugin.selectedOverlay.name !== null) {
    +                $('#' + plugin.mmFileName).val(plugin.selectedOverlay.name);
    +                $('#' + plugin.mmMetaName).val(configManager.getMetaField('name'));
    +                $('#' + plugin.mmMetaDescription).val(configManager.getMetaField('description'));
    +                $('#' + plugin.mmMetaBrand).val(configManager.getMetaField('camerabrand'));
    +                $('#' + plugin.mmMetaModel).val(configManager.getMetaField('cameramodel'));
    +                $('#' + plugin.mmMetaResolutionWidth).val(configManager.getMetaField('cameraresolutionwidth'));
    +                $('#' + plugin.mmMetaResolutionHeight).val(configManager.getMetaField('cameraresolutionheight'));
    +                $('#' + plugin.mmMetaTod).val(configManager.getMetaField('tod'));
    +            } else {
    +                $('.' + plugin.mmMetaData ).val('');
    +                $('.' + plugin.mmMetaData ).prop('disabled', true);
    +            }
    +
    +            let selectOption = $('#' + plugin.mmEditSelect).find(':selected');
    +            if (selectOption.data('type') === 'allsky') {
    +                $('#' + plugin.mmNewDialogDelete).addClass('disabled');
    +            }
    +
    +            $('#' + plugin.mmConfigSave).addClass('disabled');             
    +            $('#' + plugin.mmMetaSave).addClass('disabled');             
    +
    +            if (plugin.debug) {
    +                $('.' + plugin.mmDebug).removeClass('hidden');
    +            }
    +
    +            function resetSelect(id, tod, includeAllsky=false, selectedValue=null) {
    +                let selectId = '#' + id;
    +                $(selectId).empty();
    +
    +                if (includeAllsky) {
    +                    for (let overlay in data.coreoverlays) {
    +                        let add = false;
    +                        if (tod == 'both') {
    +                            add = true;
    +                        } else {
    +                            let overlayTod = data.coreoverlays[overlay].metadata.tod;
    +                            if (overlayTod !== undefined) {
    +                                if (overlayTod === tod || overlayTod === 'both') {
    +                                    add = true;
    +                                }
    +                            } else {
    +                                add = true;
    +                            }
    +                        }
    +
    +                        if (add) {
    +                            let selected = '';
    +                            if (selectedValue !== null && selectedValue === overlay) {
    +                                selected = 'selected';
    +                            }
    +                            let name = data.coreoverlays[overlay].metadata.name
    +                            $(selectId).append($('<option ' + selected + '>').val(overlay).text('Allsky - ' + name).data('type','allsky'));
    +                        }
    +                    }
    +                }
    +
    +                for (let overlay in data.useroverlays) {
    +                    let add = false;
    +                    if (tod == 'both') {
    +                        add = true;
    +                    } else {
    +                        let overlayTod = data.useroverlays[overlay].metadata.tod;
    +                        if (overlayTod !== undefined) {
    +                            if (overlayTod === tod || overlayTod === 'both') {
    +                                add = true;
    +                            }
    +                        } else {
    +                            add = true;
    +                        }
    +                    }
    +                    if (add) {                    
    +                        let selected = '';
    +                        if (selectedValue !== null && selectedValue === overlay) {
    +                            selected = 'selected';
    +                        }
    +                        let name = data.useroverlays[overlay].metadata.name
    +                        $(selectId).append($('<option ' + selected + '>').val(overlay).text('User - ' + name).data('type','user'));
    +                    }
    +                }                
    +            }
    +        }
    +
    +        plugin.init();       
    +    }
    +
    +    $.fn.allskyMM = function (options) {
    +        return this.each(function () {
    +            if (undefined == $(this).data('allskyMM')) {
    +                var plugin = new $.allskyMM(this, options);
    +                $(this).data('allskyMM', plugin);
    +            }
    +        });
    +    }
    +
    +})(jQuery);
    diff --git a/html/js/jquery-roi/jquery-roi.js b/html/js/jquery-roi/jquery-roi.js
    index f85ba2c14..356bb280b 100644
    --- a/html/js/jquery-roi/jquery-roi.js
    +++ b/html/js/jquery-roi/jquery-roi.js
    @@ -6,6 +6,7 @@
                 fallbackValue: 5,
                 dirty: false,
                 roi: null,
    +            imageFile: '',
                 roiSelected: function (roi) { }
             }
     
    @@ -212,7 +213,8 @@
                       }
     
                 };
    -            imageObj.src = 'current/tmp/image.jpg?_ts=1662585950244';
    +            let srcImage = plugin.settings.imageFile + '?_ts=' + Date.now();
    +            imageObj.src = srcImage;
     
                 $('#' + plugin.rioId).modal({
                     keyboard: false
    diff --git a/html/js/modules/modules.js b/html/js/modules/modules.js
    index 106908091..8000b3743 100644
    --- a/html/js/modules/modules.js
    +++ b/html/js/modules/modules.js
    @@ -489,10 +489,12 @@ class MODULESEDITOR {
                             if (fallbackValue === undefined) {
                                 fallbackValue = 5;
                             }
    +
                             $.allskyROI({
                                 id: key,
                                 roi: roi,
                                 fallbackValue: fallbackValue,
    +                            imageFile : this.#settings.filename,
                                 roiSelected: function(roi) {
                                     $('#' + key).val(roi.x1 + ',' + roi.y1 + ',' + roi.x2 + ',' + roi.y2)
                                 }
    @@ -867,13 +869,13 @@ class MODULESEDITOR {
                 html += '<div class="col-md-7"><strong>Result</strong></div>';
                 html += '</div>';
     
    -            for (let key in result.selected) {
    -                let data = result.selected[key];
    +            for (let key in result.debug) {
    +                let data = result.debug[key];
                     let runTime = parseFloat(data.lastexecutiontime);
                     totalTime += runTime;
     
                     html += '<div class="row">';                
    -                html += '<div class="col-md-3">' + data.module + '</div>';
    +                html += '<div class="col-md-3">' + key + '</div>';
                     html += '<div class="col-md-2"><div class ="pull-right">' + runTime.toFixed(2) + '</div></div>';
                     html += '<div class="col-md-7">' + data.lastexecutionresult + '</div>';
                     html += '</div>';
    @@ -949,8 +951,8 @@ class MODULESEDITOR {
                     if (result.periodictimer == undefined) {
                         result.periodictimer = 5;
                     }
    -                $('#enablewatchdog').prop('checked', result.watchdog);
    -                $('#watchdog-timeout').val(result.timeout);                
    +                //$('#enablewatchdog').prop('checked', result.watchdog);
    +                //$('#watchdog-timeout').val(result.timeout);                
                     $('#autoenable').prop('checked', result.autoenable);
                     $('#debugmode').prop('checked', result.debugmode);
                     $('#periodic-timer').val(result.periodictimer);                  
    @@ -967,8 +969,8 @@ class MODULESEDITOR {
                     $.LoadingOverlay('show', {text : 'Sorry this is taking longer than expected ...'});
                 }, 500)
     
    -            this.#moduleSettings.watchdog = $('#enablewatchdog').prop('checked');
    -            this.#moduleSettings.timeout = $('#watchdog-timeout').val() | 0;
    +            //this.#moduleSettings.watchdog = $('#enablewatchdog').prop('checked');
    +            //this.#moduleSettings.timeout = $('#watchdog-timeout').val() | 0;
                 this.#moduleSettings.autoenable = $('#autoenable').prop('checked');
                 this.#moduleSettings.debugmode = $('#debugmode').prop('checked');
     
    diff --git a/html/js/oe-config.js b/html/js/oe-config.js
    deleted file mode 100644
    index a6111ff70..000000000
    --- a/html/js/oe-config.js
    +++ /dev/null
    @@ -1,354 +0,0 @@
    -"use strict";
    -class OECONFIG {
    -
    -    #config = {};
    -    #appConfig = {};
    -    #dataFields = {};
    -    #overlayDataFields = {};
    -    #BASEDIR = 'annotater/';
    -
    -    #lastConfig = [];
    -
    -    constructor() {
    -    }
    -
    -    get config() {
    -        return this.#config;
    -    }
    -    set config(config) {
    -        this.#config = config;
    -    }
    -
    -    get appConfig() {
    -        return this.#appConfig;
    -    }
    -
    -    /**
    -     * 
    -     * Loads a configuration(s)
    -     * 
    -     * @returns 
    -     */
    -    async loadConfig() {
    -        let result;
    -
    -        try {
    -            result = await $.ajax({
    -                type: "GET",
    -                url: "includes/overlayutil.php?request=Config",
    -                data: "",
    -                dataType: 'json',
    -                cache: false
    -            });
    -
    -            this.#config = result;
    -            if (this.validateConfig()) {
    -
    -                result = await $.ajax({
    -                    type: "GET",
    -                    url: "includes/overlayutil.php?request=Data",
    -                    data: "",
    -                    dataType: 'json',
    -                    cache: false
    -                });
    -                this.#dataFields = result;
    -
    -                result = await $.ajax({
    -                    type: "GET",
    -                    url: "includes/overlayutil.php?request=OverlayData",
    -                    data: "",
    -                    dataType: 'json',
    -                    cache: false
    -                });
    -                this.#overlayDataFields = result;
    -
    -                this.#appConfig = await $.ajax({
    -                    type: "GET",
    -                    url: "includes/overlayutil.php?request=AppConfig",
    -                    data: "",
    -                    dataType: 'json',
    -                    cache: false
    -                });
    -
    -                return true;
    -            } else {
    -                return false;
    -            }
    -
    -        } catch (error) {
    -            confirm('A fatal error has occureed loading the application configuration.')
    -            return false;
    -        }
    -    }
    -
    -    get gridVisible() {
    -        return this.#appConfig.gridVisible;
    -    }
    -    set gridVisible(state) {
    -        this.#appConfig.gridVisible = state;
    -    }
    -
    -    get gridSize() {
    -        return this.#appConfig.gridSize;
    -    }
    -    set gridSize(size) {
    -        this.#appConfig.gridSize = parseInt(size);
    -    }
    -
    -    get gridColour() {
    -        let colour = '#ffffff'
    -        if (this.#appConfig.hasOwnProperty('gridColour')) {
    -            colour = this.#appConfig.gridColour;
    -        }
    -        return colour;
    -    }
    -    set gridColour(colour) {
    -        this.#appConfig.gridColour = colour;
    -    }
    -
    -    get gridOpacity() {
    -        return this.#appConfig.gridOpacity;
    -    }
    -    set gridOpacity(opacity) {
    -        this.#appConfig.gridOpacity = parseFloat(opacity);
    -    }
    -
    -    get snapBackground() {
    -        return this.#appConfig.snapBackground;
    -    }
    -    set snapBackground(state) {
    -        this.#appConfig.snapBackground = state;
    -    }
    -
    -    get addListPageSize() {
    -        return this.#appConfig.addlistpagesize;
    -    }
    -    set addListPageSize(size) {
    -        this.#appConfig.addlistpagesize = parseInt(size);
    -    }
    -
    -    get addFieldOpacity() {
    -        return this.#appConfig.addfieldopacity;
    -    }
    -    set addFieldOpacity(opacity) {
    -        this.#appConfig.addfieldopacity = parseInt(opacity);
    -    }
    -
    -    get selectFieldOpacity() {
    -        return this.#appConfig.selectfieldopacity;
    -    }
    -    set selectFieldOpacity(opacity) {
    -        this.#appConfig.selectfieldopacity = parseInt(opacity);
    -    }
    -
    -    get mouseWheelZoom() {
    -        return this.#appConfig.mousewheelzoom;
    -    }
    -    set mouseWheelZoom(state) {
    -        this.#appConfig.mousewheelzoom = state;
    -    }
    -
    -    get backgroundImageOpacity() {
    -        return this.#appConfig.backgroundopacity;
    -    }
    -    set backgroundImageOpacity(opacity) {
    -        this.#appConfig.backgroundopacity = parseInt(opacity);
    -    }
    -
    -    get allDataFields() {
    -        return this.#overlayDataFields.data;
    -    }
    -
    -    get dataFields() {
    -        return this.#dataFields.data;
    -    }
    -    set dataFields(dataFields) {
    -        return this.#dataFields = dataFields;
    -    }
    -
    -    backupConfig() {
    -        this.#lastConfig= JSON.parse(JSON.stringify(this.#config));
    -    }
    -
    -    saveFields() {
    -        try {
    -            $.ajax({
    -                type: 'POST',
    -                url: 'includes/overlayutil.php?request=Data',
    -                data: { data: JSON.stringify(this.#dataFields) },
    -                dataType: 'json',
    -                cache: false
    -            });
    -        } catch (error) {
    -            console.log(error); // TODO: Daal with corrupt config
    -            return false;
    -        }
    -    }
    -
    -    findFieldByName(name) {
    -        let result = null;
    -
    -        for (let key in this.#dataFields.data) {
    -            if (this.#dataFields.data[key].name === name) {
    -                result = this.#dataFields.data[key];
    -                break;
    -            }
    -        }
    -        return result;
    -    }
    -
    -    addField(field) {
    -        this.#dataFields.data.push(field);
    -    }
    -
    -    deletefieldById(id) {
    -        let result = null;
    -
    -        for (let i = 0; i < this.#dataFields.data.length; i++) {
    -            if (this.#dataFields.data[i].id == id) {
    -                result = i;
    -                break;
    -            }
    -        }
    -
    -        if (result !== null) {
    -            this.#dataFields.data = this.#dataFields.data.filter(function (element) {
    -                return element.id != id;
    -            });
    -        }
    -        return result;
    -    }
    -
    -    findAllFieldsById(id) {
    -        let result = null;
    -
    -        for (let i = 0; i < this.#overlayDataFields.data.length; i++) {
    -            if (this.#overlayDataFields.data[i].id == id) {
    -                result = this.#overlayDataFields.data[i];
    -                break;
    -            }
    -        }
    -        return result;
    -    }
    -
    -    findFieldById(id) {
    -        let result = null;
    -
    -        for (let i = 0; i < this.#dataFields.data.length; i++) {
    -            if (this.#dataFields.data[i].id == id) {
    -                result = this.#dataFields.data[i];
    -                break;
    -            }
    -        }
    -        return result;
    -    }
    -
    -    saveSettings() {
    -        try {
    -            $.ajax({
    -                type: 'POST',
    -                url: 'includes/overlayutil.php?request=AppConfig',
    -                data: { settings: JSON.stringify(this.#appConfig) },
    -                dataType: 'json',
    -                cache: false
    -            });
    -        } catch (error) {
    -            console.log(error); // TODO: Daal with corrupt config
    -            return false;
    -        }
    -    }
    -
    -    async saveConfig() {
    -        result = await $.ajax({
    -            type: 'POST',
    -            url: 'includes/overlayutil.php?request=Config',
    -            data: { config: JSON.stringify(this.#config) },
    -            dataType: 'json',
    -            cache: false
    -        });
    -    }
    -
    -    saveConfig1() {
    -        $.ajax({
    -            type: 'POST',
    -            url: 'includes/overlayutil.php?request=Config',
    -            data: { config: JSON.stringify(this.#config) },
    -            cache: false
    -        }).done(function() {
    -        }).fail(function() {
    -            bootbox.alert('Failed to save the overlay config. Please check the permissions on the ~/allsky/config/overlay/config/overlay.json file');
    -        });
    -    }
    -
    -    /**
    -     * TODO: Validate config
    -     * 
    -     * @returns Validate the loaded config file
    -     */
    -    validateConfig() {
    -        return true;
    -    }
    -
    -    /**
    -     * 
    -     * Get a value from the config file using dot notation and of not found return a default value.
    -     * 
    -     *      "exposure": {
    -     *          "label": "Exposure: ${exposure}",
    -     *          "fontcolour": "blue",
    -     *          "font": "led",
    -     *          "position" : {
    -     *              "x": 10,
    -     *              "y": 50
    -     *          }            
    -     *       },
    -     *
    -     * passing path as 'exposure.position.x' will return 10
    -     * 
    -     * @param {*} path The path to the value to be returned
    -     * @param {*} defaultValue Default value if the path does not exist
    -     * 
    -     * @returns {*} the result !
    -     */
    -    getValue(path, defaultValue) {
    -        return path.split('.').reduce((o, p) => o ? o[p] : defaultValue, this.#config);
    -    }
    -
    -    getBackupValue(path, defaultValue) {
    -        return path.split('.').reduce((o, p) => o ? o[p] : defaultValue, this.#lastConfig);
    -    }
    -
    -    /**
    -     * 
    -     * Sets a value givena a path, see 'getValue' for details of how the path works
    -     * 
    -     * @param {*} path The path to the value to be set
    -     * @param {*} value The value to be set
    -     * 
    -     * @returns 
    -     */
    -    setValue(path, value) {
    -        return path.split('.').reduce((o, p, i) => o[p] = path.split('.').length === ++i ? value : o[p] || {}, this.#config);
    -    }
    -
    -    /**
    -     * 
    -     * Deletes a value specified by the path
    -     * 
    -     * @param {*} path 
    -     * @returns 
    -     */
    -    deleteValue(path) {
    -        let currentObject = this.#config
    -        const parts = path.split(".")
    -        const last = parts.pop()
    -        for (const part of parts) {
    -            currentObject = currentObject[part]
    -            if (!currentObject) {
    -                return
    -            }
    -        }
    -        delete currentObject[last]
    -    }
    -
    -}
    \ No newline at end of file
    diff --git a/html/js/oe-exposure.js b/html/js/oe-exposure.js
    deleted file mode 100644
    index e96c82c03..000000000
    --- a/html/js/oe-exposure.js
    +++ /dev/null
    @@ -1,151 +0,0 @@
    -"use strict";
    -/**
    - * Class for handling the auto exposure region editor.
    - */
    -class OEEXPOSURE {
    -
    -    #exposureStage = null;
    -    #exposureLayer = null
    -    #exposureRect = null;
    -    #transformer = null;
    -    #exposureBackgroundImage = null;
    -    #stageScale = 0.6;
    -    #stageMode = 'fit';
    -
    -    constructor() {
    -
    -        var imageObj = new Image();
    -        imageObj.src = $('#oe-background-image').attr('src');
    -
    -
    -        this.#exposureBackgroundImage = new Konva.Image({
    -            x: 0,
    -            y: 0,
    -            image: imageObj,
    -        });
    -
    -        var width = this.#exposureBackgroundImage.width();
    -        var height = this.#exposureBackgroundImage.height();
    -
    -        this.#exposureStage = new Konva.Stage({
    -            container: 'oe-exposure-stage',
    -            width: width,
    -            height: height,
    -            draggable: true
    -        });
    -
    -        this.#exposureLayer = new Konva.Layer();
    -        this.#exposureLayer.add(this.#exposureBackgroundImage);
    -        this.#exposureStage.add(this.#exposureLayer);     
    -        this.setZoom('oe-autoexposure-zoom-fit');
    -    }
    -
    -    setZoom(type) {
    -        this.#stageMode = '';
    -        switch (type) {
    -            case 'oe-autoexposure-zoom-in':
    -                this.#stageScale += 0.01;
    -                this.#exposureStage.draggable(true);
    -                break;
    -
    -            case 'oe-autoexposure-zoom-out':
    -                this.#stageScale -= 0.01;
    -                this.#exposureStage.draggable(true);
    -                break;
    -
    -            case 'oe-autoexposure-zoom-full':
    -                this.#stageScale = 1;
    -                this.#exposureStage.draggable(true);
    -                break;
    -
    -            case 'oe-autoexposure-zoom-fit':
    -                let width = $('#oe-viewport').width();
    -                if (this.#exposureBackgroundImage.width() > width) {
    -                    this.#stageScale = width / this.#exposureBackgroundImage.width();
    -                    this.#exposureStage.position({ x: 0, y: 0 });
    -                    this.#exposureStage.draggable(false);
    -                    this.#stageMode = 'fit';
    -                } else {
    -                    this.#stageScale = 1;
    -                    this.#exposureStage.draggable(false);                    
    -                }                
    -                break;
    -        }
    -
    -        this.#exposureStage.scale({ x: this.#stageScale, y: this.#stageScale });
    -    }
    -
    -    start() {
    -        $(document).on('click', '#oe-autoexposure-save', { self: this }, function (event) {
    -            self = event.data.self;
    -            let rect = self.#exposureRect;
    -            let pos = rect.position();
    -
    -            let position = {
    -                x: pos.x | 0,
    -                y: pos.y | 0,
    -                xrad: (rect.radiusX() * rect.scaleX()) | 0,
    -                yrad: (rect.radiusY() * rect.scaleY()) | 0,
    -                stagewidth: self.#exposureStage.width(),
    -                stageheight: self.#exposureStage.height()
    -            }
    -
    -            $.ajax({
    -                type: 'POST',
    -                url: 'includes/overlayutil.php?request=AutoExposure',
    -                data: {data: JSON.stringify(position)},
    -                dataType: 'json',
    -                cache: false
    -            });
    -        });
    -
    -        $(document).on('click', '#oe-autoexposure-reset', (event) => {
    -            let width = this.#exposureBackgroundImage.width();
    -            let height = this.#exposureBackgroundImage.height();
    -            let maskDiameter = (height / 3) | 0;
    -            let maskRadius = (maskDiameter / 2) | 0;
    -            let x = ((width / 2) | 0);
    -            let y = ((height / 2) | 0);
    -            this.#exposureRect.x(x);
    -            this.#exposureRect.y(y);
    -            this.#exposureRect.radiusX(maskRadius);
    -            this.#exposureRect.radiusY(maskRadius);
    -        });
    -
    -        $(document).on('click', '.oe-autoexposure-zoom', (event) => {
    -            this.setZoom(event.currentTarget.id);
    -        });
    -
    -        $.ajax({
    -            type: "GET",
    -            url: "includes/overlayutil.php?request=AutoExposure",
    -            data: "",
    -            dataType: 'json',
    -            cache: false,
    -            context: this
    -        }).done(function( data ) {
    -
    -            this.#exposureRect= new Konva.Ellipse({
    -                x: data.x,
    -                y: data.y,
    -                radiusX: data.xrad,
    -                radiusY: data.yrad,
    -                fill: 'red',
    -                name: 'rect',
    -                opacity: 0.3,
    -                draggable: true,                
    -              })
    -              this.#exposureLayer.add(this.#exposureRect );
    -    
    -              this.#transformer = new Konva.Transformer({
    -                rotateEnabled: false
    -              });
    -              this.#exposureLayer.add(this.#transformer);
    -        
    -              this.#transformer.nodes([this.#exposureRect]);
    -    
    -              this.#exposureLayer.batchDraw();
    -        });
    -
    -    }
    -}
    \ No newline at end of file
    diff --git a/html/js/oe-overlayeditor.js b/html/js/oe-overlayeditor.js
    deleted file mode 100644
    index 725b946a1..000000000
    --- a/html/js/oe-overlayeditor.js
    +++ /dev/null
    @@ -1,60 +0,0 @@
    -"use strict";
    -class OVERLAYEDITOR {
    -    #container = "";
    -
    -    constructor(container, image) {
    -        this.#container = container;
    -
    -        this.#addDiContainer();
    -
    -        let config = new OECONFIG();
    -        window.oedi.add('config', config);
    -        let fieldManager = new OEFIELDMANAGER();
    -        window.oedi.add('fieldmanager', fieldManager);
    -        let uiManager = new OEUIMANAGER(image);
    -        window.oedi.add('uimanager', uiManager);
    -        window.oedi.add('BASEDIR', 'overlay/');    
    -        window.oedi.add('IMAGEDIR', 'overlay/images/');       
    -    }
    -
    -    async #loadFonts() {
    -        let config = window.oedi.get('config');
    -        let fonts = config.getValue('fonts', {});
    -        for (let font in fonts) {
    -            let fontData = config.getValue('fonts.' + font, {});
    -
    -            let fontFace = new FontFace(font, 'url(' + window.oedi.get('BASEDIR') + fontData.fontPath + ')');
    -            await fontFace.load();
    -            document.fonts.add(fontFace);
    -        }
    -    }
    -
    -    async buildUI() {
    -        $.LoadingOverlay('show');
    -        if (await window.oedi.get('config').loadConfig()) {
    -            await this.#loadFonts();
    -
    -            window.oedi.get('fieldmanager').parseConfig();
    -            window.oedi.get('uimanager').buildUI();
    -            $.LoadingOverlay('hide');
    -        }        
    -    }
    -
    -    /**
    -     * A DI container for the overlay editor. This reduces a lot of issues with scope in 3rd party
    -     * libraries.
    -     */
    -    #addDiContainer() {
    -        window.oedi = {
    -            dependencies: {},
    -            add: function(qualifier, obj) {
    -                this.dependencies[qualifier] = obj;
    -            },
    -            get: function(qualifier) {
    -                return this.dependencies[qualifier];
    -            }
    -        };
    -
    -    }
    -
    -}
    \ No newline at end of file
    diff --git a/html/js/oe-uimanager.js b/html/js/oe-uimanager.js
    deleted file mode 100644
    index 9d6bff84f..000000000
    --- a/html/js/oe-uimanager.js
    +++ /dev/null
    @@ -1,2100 +0,0 @@
    -"use strict";
    -/**
    - * The main User Interface manager. This class is responsible for controlling the entire
    - * ui for the overlay editor.
    - */
    -class OEUIMANAGER {
    -    #selected = null;
    -    #fonts = [];
    -    #configManager = null;
    -    #fieldManager = null;
    -    #snapRectangle = null;
    -    #oeEditorStage = null;
    -    #gridLayer = null;
    -    #backgroundLayer = null;
    -    #overlayLayer = null;
    -    #transformer = null;
    -    #movingField = null;
    -    #testMode = false;
    -    #backgroundImage = null;
    -    #stageScale = 0.6;
    -    #stageMode = 'fit';   
    -    #imageCache = null;
    -
    -    #fieldTable = null;
    -    #allFieldTable = null;
    -
    -    #debugMode = false;
    -    #debugPosMode = false;
    -
    -    constructor(imageObj) {
    -
    -        this.#configManager = window.oedi.get('config');
    -        this.#fieldManager = window.oedi.get('fieldmanager');
    -
    -        Konva.pixelRatio = 1;
    -
    -        this.#backgroundImage = new Konva.Image({
    -            x: 0,
    -            y: 0,
    -            image: imageObj,
    -        });
    -
    -        var width = this.#backgroundImage.width();
    -        var height = this.#backgroundImage.height();
    -
    -        $('#overlay_container').height(height);
    -        this.#oeEditorStage = new Konva.Stage({
    -            container: 'oe-editor-stage',
    -            width: width,
    -            height: height,
    -            draggable: true
    -        });
    -                
    -        this.#backgroundLayer = new Konva.Layer();
    -        this.#backgroundLayer.add(this.#backgroundImage);
    -        this.#oeEditorStage.add(this.#backgroundLayer);
    -
    -        this.#gridLayer = new Konva.Layer();
    -        this.#overlayLayer = new Konva.Layer();
    -        this.#oeEditorStage.add(this.#overlayLayer);
    -        this.#oeEditorStage.add(this.#gridLayer);
    -
    -        this.#transformer = new Konva.Transformer({
    -            resizeEnabled: false
    -        });
    -        this.#overlayLayer.add(this.#transformer);
    -
    -        this.setZoom('oe-zoom-fit');
    -
    -        this.#snapRectangle = new Konva.Rect({
    -            x: 0,
    -            y: 0,
    -            name: 'snapRectangle',
    -            width: 100,
    -            height: 50,
    -            fill: '#cccccc',
    -            opacity: 0.6,
    -            stroke: '#333',
    -            strokeWidth: 1,
    -            visible: false
    -        });
    -        this.#overlayLayer.add(this.#snapRectangle);
    -
    -        this.#oeEditorStage.on('mousemove', (e) => {
    -            let mousePos = this.#oeEditorStage.getPointerPosition();
    -            this.updateDebugWindowMousePos(mousePos.x, mousePos.y);
    -            this.updateDebugWindow();
    -        });
    -
    -        let params = this.getQueryParams(window.location.href);
    -
    -        if (params.hasOwnProperty('debug')) {
    -            if (params.debug == 'true') {
    -                this.#debugMode = true;
    -            }
    -        }
    -
    -        if (params.hasOwnProperty('debugpos')) {
    -            if (params.debugpos == 'true') {
    -                this.#debugPosMode = true;
    -            }
    -        }
    -
    -    }
    -
    -    getQueryParams(url) {
    -        const paramArr = url.slice(url.indexOf('?') + 1).split('&');
    -        const params = {};
    -        paramArr.map(param => {
    -            const [key, val] = param.split('=');
    -            params[key] = decodeURIComponent(val);
    -        })
    -        return params;
    -    }
    -
    -    get selected() {
    -        return this.#selected;
    -    }
    -    set selected(selected) {
    -        this.#selected = selected;
    -    }
    -
    -    get testMode() {
    -        return this.#testMode;
    -    }
    -    set testMode(state) {
    -        this.#testMode = state;
    -    }
    -
    -    get editorStage() {
    -        return this.#oeEditorStage;
    -    }
    -
    -    get transformer() {
    -        return this.#transformer;
    -    }
    -
    -    #resizeWindow() {
    -        if (this.#stageMode === 'fit') {
    -            this.setZoom('oe-zoom-fit');
    -        }
    -    }
    -
    -    buildUI() {
    -        this.setupFonts();
    -
    -        let fields = this.#fieldManager.fields;
    -        for (let [fieldName, field] of fields.entries()) {
    -            let object = field.shape;
    -
    -            this.#overlayLayer.add(object);
    -        }
    -
    -        $(window).on('resize', (event) => {
    -            this.#resizeWindow();
    -        });
    -
    -        jQuery(window).bind('beforeunload', ()=> {
    -            if (this.#fieldManager.dirty) {
    -                return ' ';
    -            } else {
    -                return undefined;
    -            }
    -        });
    -
    -        this.#oeEditorStage.on('dragmove', (event) => {
    -
    -            let stage = event.target;
    -            if (stage.getClassName() === 'Stage') {
    -                let x = stage.x();
    -                let y = stage.y();
    -                let viewPortWidth = $('#oe-viewport').width();
    -                let StageWidth = stage.width();
    -                let xDiff = StageWidth - viewPortWidth;
    -
    -                if (x < -xDiff) {
    -                    stage.x(-xDiff);
    -                }
    -                if (x > 0) {
    -                    stage.x(0);
    -                }
    -
    -                if (y > 0) {
    -                    stage.y(0);
    -                }
    -
    -                event.evt.preventDefault();
    -                event.evt.stopPropagation();
    -            }
    -
    -
    -        });
    -
    -        this.#oeEditorStage.on('wheel', (event) => {
    -            event.evt.preventDefault();
    -
    -            if (this.#configManager.mouseWheelZoom) {
    -                let direction = event.evt.deltaY > 0 ? 1 : -1;
    -
    -                if (event.evt.ctrlKey) {
    -                    direction = -direction;
    -                }
    -
    -                this.#stageScale = this.#stageScale + (direction * 0.01);
    -                this.#oeEditorStage.scale({ x: this.#stageScale, y: this.#stageScale });
    -            }
    -        });
    -
    -        this.#oeEditorStage.on('click tap', (event) => {
    -            let shape = event.target;
    -
    -            this.updateToolbar();
    -
    -            // if click on empty area - remove all selections
    -            if (event.target === this.#backgroundImage) {
    -                this.#transformer.nodes([]);
    -                this.hidePropertyEditor();
    -                this.#selected = null;
    -
    -                this.setFieldOpacity(false);
    -                this.updateToolbar();
    -                this.updateDebugWindow();
    -                return;
    -            }
    -
    -            // do nothing if clicked NOT on our rectangles
    -            if (!event.target.hasName('field')) {
    -                return;
    -            }
    -
    -            this.#transformer.resizeEnabled(false);
    -            this.setTransformerState(shape);
    -
    -            if (event.target.getClassName() === 'Image') {
    -                this.#transformer.resizeEnabled(true);
    -                this.#transformer.keepRatio(true);
    -                this.#transformer.enabledAnchors(['top-left', 'top-right', 'bottom-left', 'bottom-right']);
    -            }
    -            this.#transformer.nodes([event.target]);
    -            this.#selected = this.#fieldManager.findField(shape);
    -            this.setFieldOpacity(false);
    -            this.setFieldOpacity(true, shape.id());
    -
    -            this.#snapRectangle.offset({x: shape.width()/2, y: shape.height()/2});
    -
    -            if (this.#transformer.nodes().length == 1) {
    -                this.updatePropertyEditor();
    -            }
    -            this.updateDebugWindow();
    -            this.updateToolbar();
    -        });
    -
    -        this.#overlayLayer.on('dblclick dbltap', (event) => {
    -            let shape = event.target;
    -
    -            if (this.#transformer.nodes().length == 1) {
    -                this.setFieldOpacity(true, shape.id());
    -                this.#selected = this.#fieldManager.findField(shape);
    -                $('#oe-delete').removeClass('disabled');
    -
    -                this.showPropertyEditor();
    -                
    -            }
    -        });
    -
    -        this.#overlayLayer.on('dragstart', (event) => {
    -            let shape = event.target;
    -
    -            this.#movingField = this.#fieldManager.findField(shape);
    -            if (this.#configManager.snapBackground) {
    -                let gridSizeX = this.#configManager.gridSize * this.#oeEditorStage.scaleX();
    -                let gridSizeY = this.#configManager.gridSize * this.#oeEditorStage.scaleY();
    -                
    -                if (gridSizeX != 0 && gridSizeY != 0) {
    -                    this.#snapRectangle.size({
    -                        width: shape.width(),
    -                        height: shape.height()
    -                    });
    -                    this.#snapRectangle.position({
    -                        x: (Math.round(shape.x() / gridSizeX) * gridSizeX) | 0,
    -                        y: (Math.round(shape.y() / gridSizeY) * gridSizeY) | 0
    -                    });
    -    //                this.#snapRectangle.offset({x: shape.width()/2, y: shape.height()/2});                
    -                    this.#snapRectangle.offsetX(shape.offsetX());
    -                    this.#snapRectangle.offsetY(shape.offsetY());
    -                    this.#snapRectangle.scale({
    -                        x: this.#movingField.scale,
    -                        y: this.#movingField.scale
    -                    });
    -                    this.#snapRectangle.visible(true);
    -                }
    -            }
    -            
    -        });
    -
    -        this.#overlayLayer.on('dragmove ', (event) => {
    -            this.moveField(event);
    -        });
    -
    -        this.#overlayLayer.on('dragend', (event) => {
    -            let shape = event.target;
    -
    -            let gridSizeX = this.#configManager.gridSize;
    -            let gridSizeY = this.#configManager.gridSize;
    -
    -            if (gridSizeX != 0 && gridSizeY != 0) {
    -                let adjustedX = shape.x() - shape.offsetX();
    -                let adjustedY = shape.y() - shape.offsetY();
    -
    -                shape.position({
    -                    x: (Math.round(adjustedX / gridSizeX) * gridSizeX) + shape.offsetX() | 0,
    -                    y: (Math.round(adjustedY / gridSizeY) * gridSizeY) + shape.offsetY() | 0
    -                });
    -            }
    -
    -            if (this.#movingField !== null) {
    -                this.#movingField.x = shape.x() | 0;
    -                this.#movingField.y = shape.y() | 0;
    -            }
    -
    -            if (this.#selected !== null) {
    -                if (this.#selected.id === shape.id()) {
    -                    this.updatePropertyEditor();
    -                }
    -            }
    -
    -            if (this.#configManager.snapBackground) {
    -                this.#snapRectangle.visible(false);
    -            }
    -            this.#movingField = null;
    -            this.updateToolbar();
    -        });
    -
    -        this.#transformer.on('transform ', (event) => {
    -            this.moveField(event);
    -        });
    -
    -        this.#transformer.on('transformend', (event) => {
    -            let shape = event.target;
    -
    -            if (this.#selected !== null) {
    -
    -                if (this.#selected instanceof OEIMAGEFIELD) {
    -                    this.#selected.scale = shape.scaleX();
    -                }
    -
    -                if (this.#selected.id === shape.id()) {
    -                    let rotation = shape.rotation();
    -                    rotation = rotation | 0;
    -                    shape.rotation(rotation);
    -                    this.#selected.rotation = shape.rotation();
    -                    this.updatePropertyEditor();
    -                }
    -                this.updateToolbar();
    -            }
    -
    -        });
    -
    -        $(document).on('dblclick', '.draggable', (event) => {
    -            if (!$(event.target).hasClass('noclick')) {
    -                this.updateSelected(event.target);
    -            }
    -        });
    -
    -        $(document).on('click', '#oe-item-list', (event) => {
    -
    -            this.#fieldTable = $('#itemlisttable').DataTable({
    -                data: this.#configManager.dataFields,
    -                autoWidth: false,
    -                pagingType: 'simple_numbers',
    -                paging: true,
    -                info: true,
    -                searching: true,
    -                pageLength: parseInt(this.#configManager.addListPageSize),
    -                lengthMenu: [ [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, -1], [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 'All']],
    -                order: [[0, 'asc']],
    -                columns: [
    -                    {
    -                        data: 'id',
    -                        width: '0px',
    -                        visible: false
    -                    }, {
    -                        data: 'name',
    -                        width: '265px'
    -                    }, {
    -                        data: 'description',
    -                        width: '200px'
    -                    }, {
    -                        data: 'format',
    -                        width: '100px'
    -                    }, {
    -                        data: 'type',
    -                        width: '60px'
    -                    }, {
    -                        data: null,
    -                        width: '100px',
    -                        render: function (item, type, row, meta) {
    -                            let buttons = '<div class="btn-group"> <button type="button" class="btn btn-primary btn-xs oe-list-add" data-id="' + item.id + '">';
    -                            buttons += '<i class="fa-solid fa-plus oe-list-add" data-id="' + item.id + '"></i></button>';
    -                            buttons += '<button style="margin-left:5px;" type="button" class="btn btn-warning btn-xs oe-list-edit" data-id="' + item.id + '">';
    -                            buttons += '<i class="fa-solid fa-pen-to-square oe-list-edit" data-id="' + item.id + '"></i></button>';
    -                            if (item.source == 'User') {
    -                                buttons += '<button style="margin-left:5px;" type="button" class="btn btn-danger btn-xs oe-list-delete" data-id="' + item.id + '"><i class="fa-solid fa-trash oe-list-delete" data-id="' + item.id + '"></i></button>';
    -                            }
    -                            buttons += '</div>';
    -                            return buttons;
    -                        }
    -                    }
    -
    -                ],
    -                fnDrawCallback: function (oSettings) {
    -                    if (oSettings._iDisplayLength >= oSettings.aoData.length) {
    -                        $(oSettings.nTableWrapper).find('.dataTables_paginate').hide();
    -                        $(oSettings.nTableWrapper).children('div').first().hide();
    -                        $(oSettings.nTableWrapper).children('div').last().hide();
    -                    } else {
    -                        $(oSettings.nTableWrapper).find('.dataTables_paginate').show();
    -                        $(oSettings.nTableWrapper).children('div').first().show();
    -                        $(oSettings.nTableWrapper).children('div').last().show();
    -                    }
    -                }
    -            });
    -
    -            if (this.#configManager.allDataFields != undefined) {
    -                $('#oe-item-list-dialog-all-table').show();
    -                $('#oe-item-list-dialog-all-error').hide();
    -                this.#allFieldTable = $('#allitemlisttable').DataTable({
    -                    data: this.#configManager.allDataFields,
    -                    autoWidth: false,
    -                    pagingType: 'simple_numbers',
    -                    paging: true,
    -                    info: true,
    -                    searching: true,
    -                    pageLength: parseInt(this.#configManager.addListPageSize),
    -                    lengthMenu: [ [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, -1], [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 'All']],
    -                    order: [[0, 'asc']],
    -                    columns: [
    -                        {
    -                            data: 'id',
    -                            width: '0px',
    -                            visible: false
    -                        }, {
    -                            data: 'name',
    -                            width: '350px'
    -                        }, {
    -                            data: 'value',
    -                            width: '200px',
    -                            render: function (item, type, row, meta) {
    -                                if ( type !== 'display' ) {
    -                                    return item;
    -                                }
    -                                if ( typeof item !== 'number' && typeof item !== 'string' ) {
    -                                    return item;
    -                                }
    -                                item = item.toString();
    -                                if ( item.length <= 30 ) {
    -                                    return item;
    -                                }
    -                                
    -                                let shortened = item.substr(0, 20-1);
    -
    -                                let escaped = item
    -                                    .replace( /&/g, '&amp;' )
    -                                    .replace( /</g, '&lt;' )
    -                                    .replace( />/g, '&gt;' )
    -                                    .replace( /"/g, '&quot;' );
    -
    -                                return '<span class="ellipsis" title="'+escaped+'">'+shortened+'&#8230;</span>';
    -                            }                        
    -                        }, {
    -                            data: null,
    -                            width: '100px',
    -                            render: function (item, type, row, meta) {
    -
    -                                let config = window.oedi.get('config');
    -                                let fields = config.dataFields;
    -                                let check = '${' + item.name.replace('AS_', '') + '}'
    -                                let found = false;
    -                                for (let key in fields) {
    -                                    if (fields[key].name == check) {
    -                                        found = true;
    -                                        break;
    -                                    }
    -                                }
    -
    -
    -                                let tableData = $('#itemlisttable').DataTable().data();
    -                                for (let key in tableData) {
    -                                    if (tableData[key].name == check) {
    -                                        found = true;
    -                                        break;
    -                                    }
    -                                }
    -
    -                                let disabled = '';
    -                                let dataId = 'data-id="' + item.id + '"';
    -                                if (found) {
    -                                    disabled = 'disabled="disabled"';
    -                                    dataId = '';
    -                                }
    -                                let buttons = '<div class="btn-group"> <button ' + disabled + ' type="button" class="btn btn-primary btn-xs oe-all-list-add" ' + dataId + '>';
    -                                buttons += '<i class="fa-solid fa-plus oe-all-list-add" ' + dataId + '></i></button>';
    -                                buttons += '</div>';
    -                                
    -                                return buttons;
    -                            }
    -                        }
    -
    -                    ],
    -                    fnDrawCallback: function (oSettings) {
    -                        if (oSettings._iDisplayLength >= oSettings.aoData.length) {
    -                            $(oSettings.nTableWrapper).find('.dataTables_paginate').hide();
    -                            $(oSettings.nTableWrapper).children('div').first().hide();
    -                            $(oSettings.nTableWrapper).children('div').last().hide();
    -                        } else {
    -                            $(oSettings.nTableWrapper).find('.dataTables_paginate').show();
    -                            $(oSettings.nTableWrapper).children('div').first().show();
    -                            $(oSettings.nTableWrapper).children('div').last().show();
    -                        }
    -                    }
    -                });
    -            } else {
    -                $('#oe-item-list-dialog-all-table').hide();
    -                $('#oe-item-list-dialog-all-error').show();
    -            }
    -
    -
    -            $('#oe-item-list-dialog-close').html('Close');
    -            $('#oe-item-list-dialog-save').addClass('hidden');
    -            $('#oe-item-list-dialog').modal({
    -                keyboard: false,
    -                width: 800
    -            })
    -            $('#oe-item-list-dialog').on('hidden.bs.modal', function () {
    -                $('#itemlisttable').DataTable().destroy();
    -                $('#allitemlisttable').DataTable().destroy();
    -            });
    -        });
    -
    -        $(document).on('click', '.oe-list-delete', (event) => {
    -
    -            event.preventDefault();
    -            event.stopPropagation();
    -            let fieldId = $(event.currentTarget).data('id');
    -
    -            let field = this.#configManager.findFieldById(fieldId);
    -
    -            if (field.source !== 'System') {
    -                if (window.confirm('Are you sure you wish to delete this item?')) {
    -                    this.#configManager.deletefieldById(field.id);
    -                    $('#itemlisttable').DataTable().clear();
    -                    $('#itemlisttable').DataTable().rows.add(this.#configManager.dataFields);
    -                    $('#itemlisttable').DataTable().draw();
    -                    if (this.#configManager.allDataFields != undefined) {
    -                        $('#allitemlisttable').DataTable().rows().invalidate().draw('page');
    -                    }
    -
    -                    $('#oe-item-list-edit-dialog').modal('hide');
    -                    $('#oe-item-list-dialog-close').html('Cancel');
    -                    $('#oe-item-list-dialog-save').removeClass('hidden');
    -                }
    -            }
    -        });
    -
    -        $(document).on('click', '.oe-all-list-add', (event) => {
    -            event.preventDefault();
    -            event.stopPropagation();
    -            let fieldId = $(event.currentTarget).data('id');
    -            let field = this.#configManager.findAllFieldsById(fieldId);
    -            let fieldName = '${' + field.name + '}';
    -            $('#oe-item-list-edit-dialog-id').val('');
    -            $('#oe-item-list-edit-dialog-name').val(fieldName.replace('AS_', ''));
    -            $('#oe-item-list-edit-dialog-description').val('');
    -            $('#oe-item-list-edit-dialog-format').val('');
    -            $('#oe-item-list-edit-dialog-sample').val('');
    -            $('#oe-item-list-edit-dialog-type option[value=Text]').attr('selected', 'selected');
    -            $('#oe-item-list-edit-dialog-source option[value=User').attr('selected', 'selected');
    -
    -            $('#oe-item-list-edit-dialog-name').closest('.form-group').removeClass('has-error');
    -            $('#oe-item-list-edit-dialog-description').closest('.form-group').removeClass('has-error');
    -
    -            $('#oe-item-list-edit-dialog-name').prop('disabled', false);
    -            $('#oe-item-list-edit-dialog-type').prop('disabled', false);
    -
    -            $('#oe-variable-edit-fash').hide();
    -
    -            $('#oe-variable-edit-title').html('Add Variable');
    -            $('#oe-item-list-edit-dialog').modal({
    -                keyboard: false
    -            });
    -
    -        });
    -
    -        $(document).on('click', '.oe-list-add', (event) => {
    -
    -            event.preventDefault();
    -            event.stopPropagation();
    -            let fieldId = $(event.currentTarget).data('id');
    -
    -            let field = this.#configManager.findFieldById(fieldId);
    -
    -            let shape = this.#fieldManager.addField('text', field.name, null, field.format, field.sample);
    -
    -            this.setFieldOpacity(true);
    -
    -            this.#overlayLayer.add(shape);
    -
    -            $('#itemlisttable').DataTable().destroy();
    -            $('#oe-item-list-dialog').modal('hide');
    -
    -            this.#selected = this.#fieldManager.findField(shape);
    -            this.#transformer.nodes([shape]);
    -            this.showPropertyEditor();
    -            this.updatePropertyEditor();
    -            this.updateToolbar();
    -            //this.#fieldManager.buildJSON();
    -            if (this.testMode) {
    -                this.enableTestMode();
    -            }
    -        });
    -
    -        $(document).on('click', '#oe-field-dialog-add-field', (event) => {
    -
    -            event.preventDefault();
    -            event.stopPropagation();
    -
    -            $('#oe-item-list-edit-dialog-id').val('');
    -            $('#oe-item-list-edit-dialog-name').val('');
    -            $('#oe-item-list-edit-dialog-description').val('');
    -            $('#oe-item-list-edit-dialog-format').val('');
    -            $('#oe-item-list-edit-dialog-sample').val('');
    -            $('#oe-item-list-edit-dialog-type option[value=Text]').attr('selected', 'selected');
    -            $('#oe-item-list-edit-dialog-source option[value=User').attr('selected', 'selected');
    -
    -            $('#oe-item-list-edit-dialog-name').closest('.form-group').removeClass('has-error');
    -            $('#oe-item-list-edit-dialog-description').closest('.form-group').removeClass('has-error');
    -
    -            $('#oe-item-list-edit-dialog-name').prop('disabled', false);
    -            $('#oe-item-list-edit-dialog-type').prop('disabled', false);
    -
    -            $('#oe-variable-edit-fash').hide();
    -
    -            $('#oe-variable-edit-title').html('Add Variable');
    -            $('#oe-item-list-edit-dialog').modal({
    -                keyboard: false
    -            });
    -
    -        });
    -
    -        $(document).on('click', '.oe-list-edit', (event) => {
    -
    -            event.preventDefault();
    -            event.stopPropagation();
    -
    -            let fieldId = $(event.currentTarget).data('id');
    -            let field = this.#configManager.findFieldById(fieldId);
    -
    -            if (field !== 'undefined') {
    -
    -                if (field.source === 'System') {
    -                    $('#oe-item-list-edit-dialog-name').prop('disabled', true);
    -                    $('#oe-item-list-edit-dialog-type').prop('disabled', true);
    -                } else {
    -                    $('#oe-item-list-edit-dialog-name').prop('disabled', false);
    -                    $('#oe-item-list-edit-dialog-type').prop('disabled', false);
    -                }
    -
    -                $('#oe-item-list-edit-dialog-name').closest('.form-group').removeClass('has-error');
    -                $('#oe-item-list-edit-dialog-description').closest('.form-group').removeClass('has-error');
    -
    -                $('#oe-item-list-edit-dialog-id').val(field.id);
    -                $('#oe-item-list-edit-dialog-name').val(field.name);
    -                $('#oe-item-list-edit-dialog-description').val(field.description);
    -                $('#oe-item-list-edit-dialog-format').val(field.format);
    -                $('#oe-item-list-edit-dialog-sample').val(field.sample);
    -                $('#oe-item-list-edit-dialog-type option[value=' + field.type + ']').attr('selected', 'selected');
    -                $('#oe-item-list-edit-dialog-source option[value=' + field.source + ']').attr('selected', 'selected');
    -
    -                if (field.source == 'System') {
    -                    $('#oe-variable-edit-fash').show();
    -                } else {
    -                    $('#oe-variable-edit-fash').hide();
    -                }
    -
    -                $('#oe-variable-edit-title').html('Edit Variable');
    -                $('#oe-item-list-edit-dialog').modal({
    -                    keyboard: false
    -                });
    -            }
    -
    -        });
    -
    -        $(document).on('click', '#oe-field-save', (event) => {
    -            let fieldId = $('#oe-item-list-edit-dialog-id').val();
    -            let fieldName = $('#oe-item-list-edit-dialog-name').val();
    -            let fieldDescription = $('#oe-item-list-edit-dialog-description').val();
    -            let fieldFormat = $('#oe-item-list-edit-dialog-format').val();
    -            let fieldSample = $('#oe-item-list-edit-dialog-sample').val();
    -
    -            let fieldType = $("#oe-item-list-edit-dialog-type option").filter(":selected").val();
    -            let fieldSource = $("#oe-item-list-edit-dialog-source option").filter(":selected").val();
    -
    -            let formValid = true;
    -            if (!fieldName.startsWith('${')) {
    -                $('#oe-item-list-edit-dialog-name').closest('.form-group').addClass('has-error');
    -                formValid = false;
    -            }
    -            if (fieldDescription == '') {
    -                $('#oe-item-list-edit-dialog-description').closest('.form-group').addClass('has-error');
    -                formValid = false;
    -            }
    -
    -            if (formValid) {
    -                if (fieldId !== '') {
    -                    let field = this.#configManager.findFieldById(fieldId);
    -                    if (field !== null) {
    -                        field.name = fieldName;
    -                        field.description = fieldDescription;
    -                        field.format = fieldFormat;
    -                        field.sample = fieldSample;
    -                        field.type = fieldType;
    -                        field.source = fieldSource;
    -                    }
    -                } else {
    -                    let fieldId = this.#configManager.dataFields.length + 1;
    -                    let newField = {
    -                        id: fieldId,
    -                        name: fieldName,
    -                        description: fieldDescription,
    -                        format: fieldFormat,
    -                        sample: fieldSample,
    -                        type: fieldType,
    -                        source: fieldSource
    -                    };
    -                    this.#configManager.addField(newField);
    -                }
    -
    -                $('#itemlisttable').DataTable().clear();
    -                $('#itemlisttable').DataTable().rows.add(this.#configManager.dataFields);
    -                $('#itemlisttable').DataTable().draw();
    -                if (this.#configManager.allDataFields != undefined) {
    -                    $('#allitemlisttable').DataTable().rows().invalidate().draw('page');
    -                }
    -                $('#oe-item-list-edit-dialog').modal('hide');
    -                $('#oe-item-list-dialog-close').html('Cancel');
    -                $('#oe-item-list-dialog-save').removeClass('hidden');
    -            }
    -        });
    -
    -        $(document).on('click', '#oe-item-list-dialog-save', (event) => {
    -            this.#configManager.saveFields();
    -            $('#oe-item-list-dialog').modal('hide');
    -        });
    -
    -        $(document).on('click', '#oe-item-list-dialog-close', (event) => {
    -            if (!$('#oe-item-list-dialog-save').hasClass('hidden')) {
    -                $.ajax({
    -                    type: "GET",
    -                    url: "includes/overlayutil.php?request=Data",
    -                    data: "",
    -                    dataType: 'json',
    -                    cache: false
    -                }).done((data) => {
    -                    this.#configManager.dataFields = data;
    -                });
    -            }
    -            $('#oe-item-list-dialog').modal('hide');
    -        });
    -
    -        $(document).on('click', '#oe-save', (event) => {
    -            if (this.#fieldManager.dirty) {
    -                this.#saveConfig();
    -            }
    -        });
    -
    -        $(document).on('click', '#oe-test-mode', (event) => {
    -            if (this.testMode) {
    -                this.disableTestMode();
    -            } else {
    -                this.enableTestMode();
    -            }
    -        });
    -
    -        $(document).keyup((event) => {
    -            if ($(event.target)[0].nodeName == 'BODY') {
    -                if (event.key === 'Backspace' || event.key === 'Delete') {
    -                    if (this.#selected !== null) {
    -                        event.stopPropagation();
    -                        event.preventDefault();
    -                        this.#deleteField(event);
    -                        return false;
    -                    }
    -                }
    -            }
    -        });
    -
    -        $(document).on('click', '#oe-delete', (event) => {
    -            this.#deleteField(event);
    -        });
    -
    -        $(document).on('click', '#oe-add-text', (event) => {
    -            let shape = this.#fieldManager.addField('text');
    -            this.#overlayLayer.add(shape);
    -
    -            this.setFieldOpacity(true, shape.id());
    -
    -            this.#selected = this.#fieldManager.findField(shape);
    -            this.showPropertyEditor();
    -            this.updatePropertyEditor();
    -            this.updateToolbar();
    -            if (this.testMode) {
    -                this.enableTestMode();
    -            }
    -        });
    -
    -        $(document).on('click', '#oe-add-image', (event) => {
    -            let shape = this.#fieldManager.addField('image');
    -            this.#overlayLayer.add(shape);
    -            this.#selected = this.#fieldManager.findField(shape);
    -            this.showPropertyEditor();
    -            this.updatePropertyEditor();
    -            this.updateToolbar();
    -        });
    -
    -        $(document).on('click', '#oe-options', (event) => {
    -            $('#defaultimagetopacity').val(this.#configManager.getValue('settings.defaultimagetopacity') * 100);
    -            $('#defaultimagerotation').val(this.#configManager.getValue('settings.defaultimagerotation'));
    -            $('#defaultfontsize').val(this.#configManager.getValue('settings.defaultfontsize'));
    -            $('#defaultfontopacity').val(this.#configManager.getValue('settings.defaultfontopacity') * 100);
    -            $('#defaulttextrotation').val(this.#configManager.getValue('settings.defaulttextrotation'));
    -            $('#defaultdatafileexpiry').val(this.#configManager.getValue('settings.defaultdatafileexpiry'));
    -            $('#oe-default-font-colour').val(this.#configManager.getValue('settings.defaultfontcolour'));
    -            $('#oe-default-stroke-colour').val(this.#configManager.getValue('settings.defaultstrokecolour'));
    -            $('#defaultnoradids').val(this.#configManager.getValue('settings.defaultnoradids'));
    -            $('#defaultincludeplanets').prop('checked', this.#configManager.getValue('settings.defaultincludeplanets'));
    -            $('#defaultincludesun').prop('checked', this.#configManager.getValue('settings.defaultincludesun'));
    -            $('#defaultincludemoon').prop('checked', this.#configManager.getValue('settings.defaultincludemoon'));
    -
    -
    -            $('#oe-default-font-colour').spectrum({
    -                type: 'color',
    -                showInput: true,
    -                showInitial: true,
    -                showAlpha: false
    -            });
    -
    -            $('#oe-default-stroke-colour').spectrum({
    -                type: 'color',
    -                showInput: true,
    -                showInitial: true,
    -                showAlpha: false
    -            });
    -
    -            var defaultfont = this.#configManager.getValue('settings.defaultfont');
    -
    -            $('#defaultfont').empty();
    -            this.#fonts.forEach(function (value, index, array) {
    -                var optionValue = value.value;
    -                var optionText = value.text;
    -                var selected = "";
    -                if (optionValue == defaultfont) {
    -                    selected = 'selected="selected"';
    -                }
    -                $('#defaultfont').append(`<option value="${optionValue}" ${selected}>${optionText}</option>`);
    -            });
    -
    -            $('#oe-app-options-show-grid').prop('checked', this.#configManager.gridVisible);
    -            $('#oe-app-options-grid-size option[value=' + this.#configManager.gridSize + ']').attr('selected', 'selected');
    -            $('#oe-app-options-grid-opacity').val(this.#configManager.gridOpacity);
    -            $('#oe-app-options-snap-background').prop('checked', this.#configManager.snapBackground);
    -            $('#oe-app-options-add-list-size option[value=' + this.#configManager.addListPageSize + ']').attr('selected', 'selected');
    -            $('#oe-app-options-add-field-opacity').val(this.#configManager.addFieldOpacity);
    -            $('#oe-app-options-select-field-opacity').val(this.#configManager.selectFieldOpacity);
    -            $('#oe-app-options-mousewheel-zoom').prop('checked', this.#configManager.mouseWheelZoom);
    -            $('#oe-app-options-background-opacity').val(this.#configManager.backgroundImageOpacity);
    -            $('#oe-app-options-grid-colour').val(this.#configManager.gridColour);
    -
    -            $('#oe-app-options-grid-colour').spectrum({
    -                type: 'color',
    -                showInput: true,
    -                showInitial: true,
    -                showAlpha: false,
    -                preferredFormat: 'hex'
    -            });            
    -            
    -            $('#optionsdialog').modal({
    -                keyboard: false
    -            });
    -
    -            $('a[href="#configoptions"]').tab('show');
    -
    -        });
    -
    -        $(document).on('click', '#oe-defaults-save', (event) => {
    -            let defaultImagOpacity = $('#defaultimagetopacity').val() / 100;
    -            let defaultImagRotation = $('#defaultimagerotation').val() | 0;
    -            let defaultFontSize = $('#defaultfontsize').val() | 0;
    -            let defaultTextRotation = $('#defaulttextrotation').val() | 0;
    -            let defaultFont = $("#defaultfont option").filter(":selected").val();
    -            let defaultFontOpacity = $('#defaultfontopacity').val() / 100;
    -            let defaultFontColour = $('#oe-default-font-colour').val();
    -            let defaultdatafileexpiry = $('#defaultdatafileexpiry').val();
    -            let defaultnoradids = $('#defaultnoradids').val();
    -            let defaultincludeplanets = $('#defaultincludeplanets').prop('checked');
    -            let defaultincludesun = $('#defaultincludesun').prop('checked');
    -            let defaultincludemoon = $('#defaultincludemoon').prop('checked');
    -            let defaultStrokeColour = $('#oe-default-stroke-colour').val();
    -            let defaultStrokeSize = $('#oe-default-stroke-size').val();
    -
    -            this.#configManager.backupConfig();
    -            this.#configManager.setValue('settings.defaultimagetopacity', defaultImagOpacity);
    -            this.#configManager.setValue('settings.defaultimagerotation', defaultImagRotation);
    -            this.#configManager.setValue('settings.defaultfontsize', defaultFontSize);
    -            this.#configManager.setValue('settings.defaultfontopacity', defaultFontOpacity);
    -            this.#configManager.setValue('settings.defaulttextrotation', defaultTextRotation);
    -            this.#configManager.setValue('settings.defaultfont', defaultFont);
    -            this.#configManager.setValue('settings.defaultfontcolour', defaultFontColour);
    -            this.#configManager.setValue('settings.defaultdatafileexpiry', defaultdatafileexpiry);
    -            this.#configManager.setValue('settings.defaultnoradids', defaultnoradids);
    -            this.#configManager.setValue('settings.defaultincludeplanets', defaultincludeplanets);
    -            this.#configManager.setValue('settings.defaultincludemoon', defaultincludemoon);
    -            this.#configManager.setValue('settings.defaultincludesun', defaultincludesun);
    -            this.#configManager.setValue('settings.defaultstrokecolour', defaultStrokeColour);
    -
    -
    -            this.#configManager.gridVisible = $('#oe-app-options-show-grid').prop('checked');
    -            this.#configManager.gridSize = $("#oe-app-options-grid-size option").filter(":selected").val();
    -            this.#configManager.gridOpacity = $('#oe-app-options-grid-opacity').val() | 0;
    -            this.#configManager.gridColour = $('#oe-app-options-grid-colour').val();
    -            this.#configManager.snapBackground = $('#oe-app-options-snap-background').prop('checked');
    -            this.#configManager.addListPageSize = $("#oe-app-options-add-list-size option").filter(":selected").val();
    -            this.#configManager.addFieldOpacity = $('#oe-app-options-add-field-opacity').val() | 0;
    -            this.#configManager.selectFieldOpacity = $('#oe-app-options-select-field-opacity').val() | 0;
    -            this.#configManager.mouseWheelZoom = $('#oe-app-options-mousewheel-zoom').prop('checked');
    -            this.#configManager.backgroundImageOpacity = $('#oe-app-options-background-opacity').val() | 0;
    -
    -            this.#fieldManager.updateFieldDefaults();
    -            this.drawGrid();
    -            this.updateBackgroundImage();
    -
    -            this.#configManager.saveSettings();
    -            this.#fieldManager.defaultsModified();
    -
    -            $('#optionsdialog').modal('hide');
    -            this.updateToolbar();
    -            this.setupDebug();
    -        });
    -
    -        $(document).on('click', '#oe-font-dialog-add-font', (event) => {
    -            if (this.#fieldManager.dirty) {
    -                if (window.confirm('This current configuration has been modified. If you continue any chnages will be lost. Would you like to continue?')) {
    -                    this.installFont();
    -                }
    -            } else {
    -                this.installFont();
    -            }
    -
    -        });
    -
    -        $(document).on('click', '#oe-font-dialog-upload-font', (event) => {
    -            if (this.#fieldManager.dirty) {
    -                if (window.confirm('This current configuration has been modified. If you continue any chnages will be lost. Would you like to continue?')) {
    -                    this.uploadFont();
    -                }
    -            } else {
    -                this.uploadFont();
    -            }
    -
    -        });
    -
    -        $(document).on('click', '#oe-upload-font', (event) => {
    -            $('#fontlisttable').DataTable().destroy();
    -            $('#fontlisttable').DataTable({
    -                ajax: 'includes/overlayutil.php?request=Fonts',
    -                dom: '<"toolbar">frtip',
    -                autoWidth: false,
    -                pagingType: 'simple_numbers',
    -                paging: true,
    -                pageLength: 20,
    -                info: false,
    -                searching: false,
    -                order: [[0, 'asc']],
    -                columns: [
    -                    {
    -                        data: 'name',
    -                        width: '150px'
    -                    }, {
    -                        data: 'path',
    -                        width: '150px',
    -                        render: function (item, type, row, meta) {
    -                            if (item.includes('msttcorefonts')) {
    -                                return 'System Font';
    -                            } else {
    -                                return item;
    -                            }
    -                        }
    -                    }, {
    -                        data: null,
    -                        width: '100px',
    -                        render: function (item, type, row, meta) {
    -
    -                            let config = window.oedi.get('config');
    -                            let defaultFont = config.getValue('settings.defaultfont');
    -
    -                            let buttons = '';
    -                            if (item.name !== 'moon_phases' && item.name !== defaultFont && !item.path.includes('msttcorefonts')) {
    -                                buttons += '&nbsp; <button type="button" class="btn btn-danger btn-xs oe-list-font-delete" data-fontname="' + item.name + '"><i class="fa-solid fa-trash"></i></button>';
    -                                buttons += '</div>';
    -                            }
    -                            return buttons;
    -                        }
    -                    }
    -
    -                ]
    -            });
    -
    -            $('#fontlistdialog').modal({
    -                keyboard: false,
    -                width: 600
    -            })
    -        });
    -
    -        $(document).on('click', '.oe-list-font-delete', (event) => {
    -            event.stopPropagation();
    -            if (window.confirm('Are you sure you wish to delete this font? If the font is in use then all fields will be set to the default font.')) {
    -                let fontName = $(event.currentTarget).data('fontname');
    -                if (fontName !== 'undefined') {
    -                    let uiManager = window.oedi.get('uimanager');
    -                    uiManager.deleteFont(fontName);
    -                }
    -            }
    -        });
    -
    -        $(document).on('click', '.oe-zoom', (event) => {
    -            this.setZoom(event.currentTarget.id);
    -        });
    -
    -        $(document).on('click', '#oe-show-image-manager', (event) => {
    -
    -            let usedImages = [];
    -            fields = this.#configManager.getValue('images', {});
    -            for (let index in fields) {
    -                let field = fields[index];
    -                let fileName = field.image;
    -                if (!usedImages.includes(fileName)) {
    -                    usedImages.push(fileName);
    -                }
    -            }
    -
    -            $('#oe-image-manager').oeImageManager({
    -                thumbnailURL: 'includes/overlayutil.php?request=Images',
    -                usedImages: usedImages
    -            });
    -            $('#oe-file-manager-dialog').modal({
    -                keyboard: false
    -            });
    -
    -            $('#oe-file-manager-dialog').on('hidden.bs.modal', () => {
    -                $('#oe-image-manager').data('oeImageManager').destroy();
    -            });
    -
    -        });
    -
    -        $(document).on('oe-imagemanager-add', (event, image) => {
    -            let shape = this.#fieldManager.addField('image', '', null, null, null, image);
    -            this.#overlayLayer.add(shape);
    -            this.#selected = this.#fieldManager.findField(shape);
    -            this.showPropertyEditor();
    -            this.updatePropertyEditor();
    -            this.updateToolbar();
    -        });
    -
    -        $(document).on('click', '#oe-toobar-debug-button', (event) => {
    -
    -            let data = JSON.stringify(this.#configManager.config);
    -            $('#oe-debug-dialog-overlay').val(data);
    -            data = JSON.stringify(this.#configManager.dataFields);
    -            $('#oe-debug-dialog-fields').val(data);
    -            data = JSON.stringify(this.#configManager.appConfig);
    -            $('#oe-debug-dialog-config').val(data);
    -
    -            $('#oe-debug-dialog').modal({
    -                keyboard: false
    -            });
    -        });
    -
    -        $('.modal').on('shown.bs.modal', this.alignModal);
    -
    -        $(window).on('resize', (event) => {
    -            $('.modal:visible').each(this.alignModal);
    -        });
    -
    -        $('[data-toggle="tooltip"]').tooltip();
    -
    -        this.updateDebugWindow();
    -        this.drawGrid();
    -        this.updateBackgroundImage();
    -        this.setupDebug();
    -        this.updateToolbar();
    -    }
    -
    -    moveField(event) {
    -        let shape = event.target;
    -
    -        if (shape.getClassName() !== 'Transformer') {
    -            if (this.#configManager.snapBackground) {
    -                let gridSizeX = this.#configManager.gridSize;
    -                let gridSizeY = this.#configManager.gridSize;
    -
    -                if (event.evt.shiftKey) {
    -                    this.#transformer.rotationSnaps([0, 90, 180, 270]);
    -                } else {
    -                    this.#transformer.rotationSnaps([]);
    -                }
    -
    -                this.#snapRectangle.rotation(shape.rotation());              
    -                this.#snapRectangle.position({
    -//                    x: (Math.round(shape.x()  / gridSizeX) * gridSizeX),
    -                    x: (Math.round((shape.x() - shape.offsetX())  / gridSizeX) * gridSizeX) +  shape.offsetX(),
    -                    y: (Math.round((shape.y() - shape.offsetY())  / gridSizeY) * gridSizeY) +  shape.offsetY()
    -                });
    -            }
    -
    -            if (this.#selected !== null) {
    -                if (event.target.id() == this.#selected.id) {
    -                    this.setTransformerState(shape);
    -                }
    -            }
    -        }
    -    }
    -
    -    setTransformerState(shape) {
    -        this.checkFieldBounds(shape, this.#oeEditorStage, this.#transformer);
    -    }
    -
    -    checkFieldBounds(shape, oeEditorStage, transformer) {
    -        if (transformer.borderStroke() !== '#00a1ff') {
    -            transformer.borderStroke('#00a1ff');
    -            transformer.borderStrokeWidth(1);
    -        }
    -
    -      
    -        let stageWidth = oeEditorStage.width();
    -        let stageHeight = oeEditorStage.height();
    -
    -        let rect = shape.getClientRect();
    -        let x = rect.x  / oeEditorStage.scaleX();
    -        let y = rect.y  / oeEditorStage.scaleY();
    -
    -        x = Math.ceil(x/oeEditorStage.scaleX())*oeEditorStage.scaleX()|0;
    -        y = Math.ceil(y/oeEditorStage.scaleY())*oeEditorStage.scaleY()|0;
    -
    -        let width = rect.width / oeEditorStage.scaleX();
    -        let height = rect.height / oeEditorStage.scaleY();
    -
    -        let outOfBounds = false;
    -        if (x < 0) {
    -            outOfBounds = true;
    -        }
    -        if (y < 0) {
    -            outOfBounds = true;
    -        }
    -
    -        if ((x + width) > stageWidth) {
    -            outOfBounds = true;
    -        }
    -        if ((y + height) > stageHeight) {
    -            outOfBounds = true;
    -        }
    -
    -        if (outOfBounds) {
    -            transformer.borderStrokeWidth(3);
    -            transformer.borderStroke('red');    
    -        }
    -
    -    }
    -
    -    #saveConfig() {
    -        this.#fieldManager.buildJSON();
    -        this.#configManager.saveConfig1();
    -        this.#fieldManager.clearDirty();
    -        this.updateToolbar();
    -    }
    -
    -    #deleteField(event) {
    -        let shape = this.#selected.shape;
    -        this.hidePropertyEditor();
    -        this.#fieldManager.deleteField(shape.id());
    -        this.#transformer.nodes([]);
    -        shape.destroy();
    -        this.setFieldOpacity(false);
    -        this.#selected = null;
    -        this.setFieldOpacity(false);
    -
    -        this.updateToolbar();
    -    }
    -
    -    setZoom(type) {
    -        this.#stageMode = '';
    -        switch (type) {
    -            case 'oe-zoom-in':
    -                this.#stageScale += 0.01;
    -                this.#oeEditorStage.draggable(true);
    -                break;
    -
    -            case 'oe-zoom-out':
    -                this.#stageScale -= 0.01;
    -                this.#oeEditorStage.draggable(true);
    -                break;
    -
    -            case 'oe-zoom-full':
    -                this.#stageScale = 1;
    -                this.#oeEditorStage.draggable(true);
    -                break;
    -
    -            case 'oe-zoom-fit':
    -                let width = $('#oe-viewport').width();
    -                if (this.#backgroundImage.width() > width) {
    -                    this.#stageScale = width / this.#backgroundImage.width();
    -                    this.#oeEditorStage.position({ x: 0, y: 0 });
    -                    this.#oeEditorStage.draggable(false);
    -                    this.#stageMode = 'fit';
    -                } else {
    -                    this.#stageScale = 1;
    -                    this.#oeEditorStage.draggable(false);
    -                }
    -                break;
    -        }
    -
    -        this.#oeEditorStage.scale({ x: this.#stageScale, y: this.#stageScale });
    -
    -        let height = (this.#backgroundImage.height() * this.#stageScale);
    -
    -        // Not very nice 'fix' to prevent the scaled stage from having a huge black block underneath it
    -        $('#overlay_container').height(height);
    -        
    -
    -    }
    -
    -    loadBackgroundImage() {
    -        var imageObj = new Image();
    -        imageObj.src = $('#oe-background-image').attr('src');
    -
    -        const load = url => new Promise(resolve => {
    -            imageObj.onload = () => resolve({ imageObj })
    -            imageObj.src = url
    -        });
    -
    -        (async () => {
    -            const { imageObj } = await load($('#oe-background-image').attr('src'));
    -        })();
    -
    -        this.#backgroundImage = new Konva.Image({
    -            x: 0,
    -            y: 0,
    -            image: imageObj,
    -        });
    -    }
    -
    -    updateToolbar() {
    -        if (this.#selected === null) {
    -            $('#oe-delete').addClass('disabled');
    -            $('#oe-delete').removeClass('green');
    -        } else {
    -            $('#oe-delete').removeClass('disabled');
    -            $('#oe-delete').addClass('green');
    -        }
    -
    -        if (this.#fieldManager.dirty) {
    -            $('#oe-save').removeClass('disabled');
    -            $('#oe-save').addClass('green pulse');
    -            $('#oe-overlay-editor-tab').addClass('oe-overlay-editor-tab-modified');            
    -        } else {
    -            $('#oe-save').addClass('disabled');
    -            $('#oe-save').removeClass('green pulse');
    -            $('#oe-overlay-editor-tab').removeClass('oe-overlay-editor-tab-modified');
    -        }
    -
    -        if (this.#debugMode) {
    -            $('#oe-toolbar-debug').removeClass('hidden')
    -        } else {
    -            $('#oe-toolbar-debug').addClass('hidden')
    -        }
    -    }
    -
    -    setupDebug() {
    -        if (this.#debugPosMode) {
    -            this.#createDebugWindow();
    -        } else {
    -            if ($('#debugdialog').hasClass('ui-dialog-content')) {
    -                $('#debugdialog').dialog('destroy');
    -            }
    -        }
    -    }
    -
    -    /**
    -     * Vertically align a Boostrap modal in the window. This makes the dialogs
    -     * far easier to see and use.
    -     */
    -    alignModal() {
    -        //let modalDialog = $(this).find(".modal-dialog");
    -
    -        // Applying the top margin on modal to align it vertically center
    -        //modalDialog.css("margin-top", Math.max(0, ($(window).height() - modalDialog.height()) / 2));
    -    }
    -
    -    uploadFont() {
    -        $('#fontuploadfile').val('');
    -        $('#fontuploadsubmit').addClass('disabled');
    -        $('#fontuploadalert').addClass('hidden');
    -
    -        $('#fontuploaddialog').modal({
    -            keyboard: false,
    -            width: 600
    -        });
    -
    -        $('#fontuploadfile').change(function() {
    -            $('#fontuploadalert').addClass('hidden');
    -
    -            var file = this.files[0];
    -            var fileType = file.type;
    -            var match = ['application/zip', 'application/zip-compressed', 'application/x-zip-compressed', 'application/x-zip'];
    -            if(!((fileType == match[0]) || (fileType == match[1]) || (fileType == match[2]) || (fileType == match[3]) )){
    -                alert('Sorry, only zip files are allowed.');
    -                $('#fontuploadfile').val('');
    -                $('#fontuploadsubmit').addClass('disabled');
    -                return false;
    -            }
    -            $('#fontuploadsubmit').removeClass('disabled');
    -        });
    -
    -        $('#fontuploadsubmit').on('click', (e) => {
    -            e.preventDefault();
    -            $.ajax({
    -                type: 'POST',
    -                url: 'includes/overlayutil.php?request=fonts',
    -                data: new FormData(document.getElementById('fontuploadform')),
    -                contentType: false,
    -                dataType: 'json',
    -                cache: false,
    -                processData:false,
    -                context: this,
    -                beforeSend: function( xhr ) {
    -                    $('.fontuploadsubmit').attr('disabled','disabled');
    -                    $('#fontuploadform').css('opacity','.5');
    -                }                
    -            }).done( (fontData) => {
    -                $('#fontuploadform').css('opacity','');
    -                $('.fontuploadsubmit').removeAttr('disabled');
    -                for (let i = 0; i < fontData.length; i++) {
    -                    let fontFace = new FontFace(fontData[i].key, 'url(' + window.oedi.get('BASEDIR') + fontData[i].path + ')');
    -                    fontFace.load();
    -                    document.fonts.add(fontFace);
    -                }
    -
    -                document.fonts.ready.then((font_face_set) => {
    -                    this.setupFonts();
    -                    $('#fontlisttable').DataTable().ajax.reload();
    -                    let result = $.ajax({
    -                        type: "GET",
    -                        url: "includes/overlayutil.php?request=Config",
    -                        data: "",
    -                        dataType: 'json',
    -                        cache: false,
    -                        context: this
    -                    }).done((data) => {
    -                        this.#configManager.config = data;
    -                    });
    -                });                
    -
    -                $('#fontuploaddialog').modal('hide');                
    -            }).fail( (jqXHR, error, errorThrown) => {
    -                $('#fontuploadform').css('opacity','');
    -                $('#fontuploadalert').removeClass('hidden');
    -                $('#fontuploadsubmit').addClass('disabled');                
    -            });
    -        });
    -
    -    }
    -
    -    installFont() {
    -        bootbox.prompt('Enter the URL of the font from daFont.com', (fontURL) => {
    -            if (fontURL !== '') {
    -                $.ajax({
    -                    url: 'includes/overlayutil.php?request=fonts',
    -                    type: 'POST',
    -                    data: { fontURL: fontURL },
    -                    dataType: 'json',
    -                    context: this
    -                }).done((fontData) => {
    -
    -                    for (let i = 0; i < fontData.length; i++) {
    -                        let fontFace = new FontFace(fontData[i].key, 'url(' + window.oedi.get('BASEDIR') + fontData[i].path + ')');
    -                        fontFace.load();
    -                        document.fonts.add(fontFace);
    -                    }
    -
    -                    document.fonts.ready.then((font_face_set) => {
    -                        this.setupFonts();
    -                        $('#fontlisttable').DataTable().ajax.reload();
    -                        let result = $.ajax({
    -                            type: "GET",
    -                            url: "includes/overlayutil.php?request=Config",
    -                            data: "",
    -                            dataType: 'json',
    -                            cache: false,
    -                            context: this
    -                        }).done((data) => {
    -                            this.#configManager.config = data;
    -                        });
    -                    });
    -                }).fail(function (jqXHR, textStatus, errorThrown) {
    -                    bootbox.alert('Please enter a valid url from daFont.com ' + errorThrown);
    -                }).fail((jqXHR, textStatus, errorThrown) => {
    -                });
    -            }
    -        });
    -    }
    -
    -    setFieldOpacity(state, ignoreId) {
    -        let opacity = this.#configManager.addFieldOpacity / 100;
    -        if (typeof ignoreId !== 'undefined') {
    -            opacity = this.#configManager.selectFieldOpacity / 100;
    -        }
    -        let shapes = this.#overlayLayer.getChildren();
    -        for (let key in shapes) {
    -            let type = shapes[key].getClassName();
    -
    -            let skip = false;
    -            if (typeof ignoreId !== 'undefined') {
    -                if (shapes[key].id() === ignoreId) {
    -                    skip = true;
    -                }
    -            }
    -
    -            if (!skip) {
    -                if (type === 'Image' || type === 'Text') {
    -                    if (state) {
    -                        shapes[key].opacity(opacity);
    -                    } else {
    -                        let field = this.#fieldManager.findField(shapes[key]);
    -                        shapes[key].opacity(field.opacity);
    -                    }
    -                }
    -            }
    -        }
    -    }
    -
    -    convertColour(colour, opacity) {
    -        if (colour.charAt(0) == "#"){
    -            colour = colour.substring(1,7);
    -        }
    -
    -        let red = parseInt(colour.substring(0,2) ,16);
    -        let green = parseInt(colour.substring(2,4) ,16);
    -        let blue = parseInt(colour.substring(4,6) ,16)
    -        opacity = opacity / 100;
    -
    -        return 'rgba(' + red.toString() + ',' + green.toString() + ',' + blue.toString() + ',' + opacity.toString() + ')';
    -    }
    -
    -    drawGrid() {
    -        this.#gridLayer.destroyChildren();
    -        if (this.#configManager.gridVisible) {
    -            if (this.#configManager.gridSize > 0) {
    -                let gridColour = this.convertColour(this.#configManager.gridColour, this.#configManager.gridOpacity)
    -                let stepSize = this.#configManager.gridSize;
    -
    -                let xSize = this.#oeEditorStage.width(),
    -                    ySize = this.#oeEditorStage.height(),
    -                    xSteps = Math.round(xSize / stepSize),
    -                    ySteps = Math.round(ySize / stepSize);
    -
    -                for (let i = 0; i <= xSteps; i++) {
    -                    this.#gridLayer.add(
    -                        new Konva.Line({
    -                            x: i * stepSize,
    -                            points: [0, 0, 0, ySize],
    -                            //stroke: 'rgba(255, 255, 255, ' + (this.#configManager.gridOpacity / 100).toString() + ')',
    -                            stroke: gridColour,
    -                            strokeWidth: 1,
    -                        })
    -                    );
    -                }
    -
    -                for (let i = 0; i <= ySteps; i++) {
    -                    this.#gridLayer.add(
    -                        new Konva.Line({
    -                            y: i * stepSize,
    -                            points: [0, 0, xSize, 0],
    -                            //stroke: 'rgba(255, 255, 255, ' + (this.#configManager.gridOpacity / 100).toString() + ')',
    -                            stroke: gridColour,
    -                            strokeWidth: 1,
    -                        })
    -                    );
    -                }
    -            }
    -        }
    -    }
    -
    -    updateBackgroundImage() {
    -        this.#backgroundLayer.opacity(this.#configManager.backgroundImageOpacity / 100);
    -    }
    -
    -    showPropertyEditor() {
    -        if (this.#selected instanceof OETEXTFIELD) {
    -            this.#createTextPropertyEditor();
    -            this.updatePropertyEditor();
    -        } else {
    -            $.ajax({
    -                url: 'includes/overlayutil.php?request=Images',
    -                type: 'GET',
    -                dataType: 'json',
    -                cache: false,
    -                context: this
    -            }).done((result) => {
    -                this.#imageCache = result;
    -                this.#createImagePropertyEditor();
    -                this.updatePropertyEditor();
    -            });
    -        }
    -    }
    -
    -    hidePropertyEditor() {
    -        if (this.#selected instanceof OETEXTFIELD) {
    -            if ($("#textdialog").hasClass('ui-dialog-content')) {
    -                $('#textdialog').dialog('close');
    -                $('#textpropgrid').jqPropertyGrid('Destroy');
    -                try {
    -                    $('#oe-default-font-colour').spectrum('Destroy');
    -                } catch (error) { }
    -            }
    -        } else {
    -            if ($("#imagedialog").hasClass('ui-dialog-content')) {
    -                $('#imagepropgrid').jqPropertyGrid('Destroy');
    -                $('#imagedialog').dialog('close');
    -            }
    -        }
    -        if ($("#formatdialog").hasClass('ui-dialog-content')) {
    -            $('#formatdialog').dialog('close');
    -        }
    -        
    -    }
    -
    -    updatePropertyEditor() {
    -        if (this.#selected !== null) {
    -
    -            let textVisible = false;
    -            if ($('#textdialog').closest('.ui-dialog').is(':visible')) {
    -                textVisible = true;
    -            }
    -            let imageVisible = false;
    -            if ($('#imagedialog').closest('.ui-dialog').is(':visible')) {
    -                imageVisible = true;
    -            }
    -
    -            if (this.#selected instanceof OETEXTFIELD) {
    -                if (imageVisible) {
    -                    $('#imagepropgrid').jqPropertyGrid('Destroy');
    -                    $('#imagedialog').dialog('close');
    -                    this.#createTextPropertyEditor();
    -                }
    -                let strokeColour = this.#selected.stroke;
    -                //if (this.#selected.strokewidth == 0) {
    -                //    strokeColour = null;
    -               // }
    -                $('#textpropgrid').jqPropertyGrid('set', {
    -                    'label': this.#selected.label,
    -                    'format': this.#selected.format,
    -                    'sample': this.#selected.sample,
    -                    'empty': this.#selected.empty,
    -                    'x': this.#selected.calcX|0,
    -                    'y': this.#selected.calcY|0,
    -                    'fontsize': this.#selected.fontsize,
    -                    'fontname': this.#selected.fontname,
    -                    'opacity': this.#selected.opacity,
    -                    'rotation': this.#selected.rotation,
    -                    'fill': this.#selected.fill,
    -                    'strokewidth': this.#selected.strokewidth,
    -                    'stroke': strokeColour
    -                });
    -            } else {
    -                if (textVisible) {
    -                    $('#textdialog').dialog('close');
    -                    $('#textpropgrid').jqPropertyGrid('Destroy');
    -                    this.#createImagePropertyEditor();                    
    -                }
    -                $('#imagepropgrid').jqPropertyGrid('set', {
    -                    'x': this.#selected.x,
    -                    'y': this.#selected.y,
    -                    'image': this.#selected.image,
    -                    'opacity': this.#selected.opacity,
    -                    'rotation': this.#selected.rotation,
    -                    'scale': this.#selected.scale
    -                });
    -            }
    -        }
    -    }
    -
    -    /**
    -     * Build an internal list of all available fonts. This is used for the drop downs on the 
    -     * text field property editor.
    -     */
    -    setupFonts() {
    -        this.#fonts = [];
    -
    -        /** Add our fonts */
    -        let fontList = Array.from(document.fonts);
    -        for (let i=0; i<fontList.length; i++) {
    -            let fontFace = fontList[i];
    -            this.#fonts.push({ 'value': fontFace.family, 'text': fontFace.family });
    -        };
    -
    -        /** Add Web safe fonts */
    -        this.#fonts.push({ 'value': 'Arial', 'text': 'Arial (sans-serif)' });
    -        this.#fonts.push({ 'value': 'Arial Black', 'text': 'Arial Black (sans-serif)' });
    -        this.#fonts.push({ 'value': 'Times New Roman', 'text': 'Times New Roman (serif)' });
    -        this.#fonts.push({ 'value': 'Courier New', 'text': 'Courier (monospace)' });
    -        this.#fonts.push({ 'value': 'Verdana', 'text': 'Verdana (sans-serif)' });
    -        this.#fonts.push({ 'value': 'Trebuchet MS', 'text': 'Trebuchet MS (sans-serif)' });
    -        this.#fonts.push({ 'value': 'Impact', 'text': 'Impact (sans-serif)' });
    -        this.#fonts.push({ 'value': 'Georgia', 'text': 'Georgia (serif)' });
    -        this.#fonts.push({ 'value': 'Comic Sans MS', 'text': 'Comic Sans MS (cursive)' });
    -    }
    -
    -    deleteFont(fontName) {
    -        let fontToDelete = null;
    -        for (let fontFace of document.fonts.values()) {
    -            if (fontFace.family == fontName) {
    -                fontToDelete = fontFace;
    -                break;
    -            }
    -        }
    -
    -        if (fontToDelete !== null) {
    -            $.LoadingOverlay('show');
    -            let result = document.fonts.delete(fontToDelete);
    -            if (result) {
    -                $.ajax({
    -                    type: 'POST',
    -                    url: 'includes/overlayutil.php?request=Config',
    -                    data: { config: JSON.stringify(this.#configManager.config) },
    -                    cache: false
    -                }).done((result) => {
    -                    $.ajax({
    -                        url: 'includes/overlayutil.php?request=font&fontName=' + fontName,
    -                        type: 'DELETE',
    -                        context: this
    -                    }).done((result) => {
    -                        this.#fieldManager.switchFontUsed(fontName);
    -                        //this.#fieldManager.buildJSON();
    -                        this.#configManager.deleteValue('fonts.' + fontName);
    -                        $.ajax({
    -                            type: 'POST',
    -                            url: 'includes/overlayutil.php?request=Config',
    -                            data: { config: JSON.stringify(this.#configManager.config) },
    -                            cache: false
    -                        }).done((result) => {
    -                            $('#fontlisttable').DataTable().ajax.reload();
    -                            $.LoadingOverlay('hide');
    -                        });
    -                    }).fail((jqXHR, textStatus, errorThrown) => {
    -                        $.LoadingOverlay('hide');
    -                    });
    -                }).fail((jqXHR, textStatus, errorThrown) => {
    -                    $.LoadingOverlay('hide');
    -                });
    -            } else {
    -                $.LoadingOverlay('hide');
    -            }
    -        }
    -    }
    -
    -    #createTextPropertyEditor() {
    -        var textData = {
    -            label: '',
    -            format: '',
    -            sample: '',
    -            empty: '',
    -            x: 0,
    -            y: 0,
    -            rotation: 0,
    -            fontname: 'df',
    -            fontsize: 32,
    -            opacity: 1,
    -            fill: '#ffffff',
    -            strokewidth: 0,
    -            stroke: ''
    -        };
    -
    -        let gridSizeX = this.#configManager.gridSize;
    -        let gridSizeY = this.#configManager.gridSize;
    -
    -        if (gridSizeX == 0) {
    -            gridSizeX = 1;
    -        }
    -        if (gridSizeY == 0) {
    -            gridSizeY = 1;
    -        }
    -
    -        var textConfig = {
    -            label: { group: 'Label', name: 'Item', type: 'text' },
    -            format: { group: 'Label', name: 'Format', type: 'text', helpcallback: function (name) {
    -                let uiManager = window.oedi.get('uimanager'); 
    -                uiManager.#createFormatHelpWindow();
    -            }},
    -            sample: { group: 'Label', name: 'Sample', type: 'text' },
    -            empty: { group: 'Label', name: 'Empty Value', type: 'text' },
    -
    -            x: { group: 'Position', name: 'X', type: 'number', options: { min: 0, max: this.#backgroundImage.width(), step: gridSizeX } },
    -            y: { group: 'Position', name: 'Y', type: 'number', options: { min: 0, max: this.#backgroundImage.height(), step: gridSizeY } },
    -            rotation: { group: 'Position', name: 'Rotation', type: 'number', options: { min: -360, max: 360, step: 1 } },
    -
    -            fontname: { group: 'Font', name: 'Name', type: 'options', options: this.#fonts },
    -            fontsize: { group: 'Font', name: 'Size', type: 'number', options: { min: 4, max: 256, step: 4 } },
    -            opacity: { group: 'Font', name: 'Opacity', type: 'number', options: { min: 0, max: 1, step: 0.1 } },
    -            fill: {
    -                group: 'Font', name: 'Colour', type: 'color', options: {
    -                    preferredFormat: 'hex',
    -                    type: "color",
    -                    showInput: true,
    -                    showInitial: true,
    -                    showAlpha: false
    -                }
    -            },
    -            strokewidth: { group: 'Font', name: 'Stroke Size', type: 'number', options: { min: 0, max: 10, step: 1 } },
    -            stroke: {
    -                group: 'Font', name: 'Stroke Colour', type: 'color', options: {
    -                    preferredFormat: 'hex',
    -                    type: "color",
    -                    showInput: true,
    -                    showInitial: true,
    -                    showAlpha: false
    -                }
    -            },
    -        };
    -
    -        function propertyChangedCallback(grid, name, value) {
    -            let uiManager = window.oedi.get('uimanager'); // YUK
    -            let field = uiManager.selected;
    -
    -            // TODO: Check setter actually exists
    -            
    -            if (name == 'x' || name == 'y') {
    -                if (value == '') {
    -                    value = 0;
    -                }
    -                value = parseInt(value)
    -                if (name == 'x') {
    -                    field.x = value + field.shape.offsetX()
    -                } else {
    -                    field.y = value + field.shape.offsetY()
    -                }
    -            } else {
    -                if (name == 'label') {
    -                    let x = field.x;
    -                    let oldSize = field.shape.measureSize(field.label); 
    -                    field[name] = value;
    -                    let size = field.shape.measureSize(field.label); 
    -                    let adj = (oldSize.width - size.width)/2;
    -                    field.shape.offsetX((size.width/2)|0);
    -                    field.x = x - adj;
    -                } else {
    -                    field[name] = value;
    -                }
    -            }
    -            uiManager.checkFieldBounds(field.shape, uiManager.editorStage , uiManager.transformer);
    -
    -            // If we are in test mode then re enable it after the field has ben updated
    -            if (uiManager.testMode) {
    -                uiManager.enableTestMode();
    -            }
    -            uiManager.updateToolbar();
    -
    -        }
    -
    -        var options = {
    -            meta: textConfig,
    -            prefix: 'text',
    -            callback: propertyChangedCallback,
    -        };
    -
    -        $('#textpropgrid').jqPropertyGrid(textData, options);
    -        $('#textdialog').dialog({
    -            resizable: false,
    -            closeOnEscape: false,
    -            width: 350,
    -            beforeClose: function (event, ui) {
    -                let uiManager = window.oedi.get('uimanager');
    -               // uiManager.selected = null;
    -                uiManager.setFieldOpacity(false);
    -            }
    -        });
    -    }
    -
    -    #createImagePropertyEditor() {
    -        let imageData = {
    -            image: '',
    -            x: 0,
    -            y: 0,
    -            rotation: 0,
    -            opacity: 0,
    -            scale: 0
    -        };
    -
    -        let images = [];
    -        images.push({
    -            value: 'missing',
    -            text: 'Select Image'
    -        });
    -        for (let index in this.#imageCache) {
    -            images.push({
    -                value: this.#imageCache[index].filename,
    -                text: this.#imageCache[index].filename
    -            });
    -        }
    -
    -        let imageConfig = {
    -            image: { group: 'Image', name: 'Image', type: 'options', options: images },
    -            //image: { group: 'Image', name: 'Image', type: 'text' },
    -            //fontname: { group: 'Font', name: 'Name', type: 'options', options: this.#fonts },
    -
    -            x: { group: 'Position', name: 'X', type: 'number', options: { min: 0, max: this.#backgroundImage.width(), step: 10 } },
    -            y: { group: 'Position', name: 'Y', type: 'number', options: { min: 0, max: this.#backgroundImage.height(), step: 10 } },
    -
    -            rotation: { group: 'Position', name: 'Rotation', type: 'number', options: { min: -360, max: 360, step: 1 } },
    -            opacity: { group: 'Position', name: 'Opacity', type: 'number', options: { min: 0, max: 1, step: 0.1 } },
    -
    -            scale: { group: 'Size', name: 'Scale', type: 'number', options: { min: 0, max: 10, step: 0.1 } },
    -
    -        };
    -
    -        function propertyChangedCallback(grid, name, value) {
    -            let uiManager = window.oedi.get('uimanager'); // YUK
    -            let field = uiManager.selected;
    -
    -            // TODO: Check setter actually exists
    -            if (name !== 'image') {
    -                field[name] = value;
    -                uiManager.checkFieldBounds(field.shape, uiManager.editorStage , uiManager.transformer);
    -            } else {
    -                field.setImage(value).then( () => {
    -                    uiManager.transformer.forceUpdate();
    -                });
    -            }
    -
    -            
    -
    -            uiManager.updateToolbar();
    -        }
    -
    -        var options = {
    -            meta: imageConfig,
    -            prefix: 'image',
    -            helpHtml: '[?]',
    -            callback: propertyChangedCallback,
    -        };
    -
    -        $('#imagepropgrid').jqPropertyGrid(imageData, options);
    -        $('#imagedialog').dialog({
    -            resizable: false,
    -            closeOnEscape: false,
    -            beforeClose: function (event, ui) {
    -                let uiManager = window.oedi.get('uimanager');
    -               // uiManager.selected = null;
    -            }
    -        });
    -    }
    -
    -    enableTestMode() {
    -        this.testMode = true;
    -        this.#fieldManager.enableTestMode();
    -    }
    -
    -    disableTestMode() {
    -        this.testMode = false;
    -        this.#fieldManager.disableTestMode();
    -    }
    -
    -    rotatePoint(pt, o, a){
    -
    -        var angle = a * (Math.PI/180);
    -        var rotatedX = Math.cos(angle) * (pt.x - o.x) - Math.sin(angle) * (pt.y - o.y) + o.x;
    -        var rotatedY = Math.sin(angle) * (pt.x - o.x) + Math.cos(angle) * (pt.y - o.y) + o.y;  
    -      
    -        return {x: rotatedX, y: rotatedY};
    -    }
    -
    -    updateDebugWindow() {
    -        if (this.#debugPosMode) {            
    -            let field = this.#selected;
    -            if (field === null) {
    -                $('#debugpropgrid').jqPropertyGrid('set', {
    -                    'type': 'N/A',
    -                    'fieldId': 'N/A',
    -                    'x': 'N/A',
    -                    'y': 'N/A',
    -                    'tlx': 'N/A',
    -                    'tly': 'N/A',                                
    -                    'offsetx': 'N/A',
    -                    'offsety': 'N/A',                
    -                    'width': 'N/A',
    -                    'height': 'N/A',
    -                    'rect': 'N/A',
    -                    'rectc': 'N/A'
    -                });            
    -            } else {
    -        
    -                let rect = field.shape.getClientRect();
    -                let rectx = rect.x  / this.#oeEditorStage.scaleX();
    -                let recty = rect.y  / this.#oeEditorStage.scaleY();
    -
    -                let gridSizeX = this.#configManager.gridSize;
    -                let gridSizeY = this.#configManager.gridSize;
    -
    -                let rectcx = (Math.round((field.shape.x() - field.shape.offsetX())  / gridSizeX) * gridSizeX);
    -                let rectcy = (Math.round((field.shape.y() - field.shape.offsetY())  / gridSizeY) * gridSizeY);
    -                
    -                let rectString = rectx.toFixed(2) + ", " + recty.toFixed(2);
    -                let rectCalc = rectcx + ", " + rectcy;
    -
    -                let scaleX = this.#oeEditorStage.scaleX();
    -                let scaleY = this.#oeEditorStage.scaleY();              
    -                let type = 'Text';
    -                if (this.#selected instanceof OEIMAGEFIELD) {
    -                    type = 'Image';
    -                }
    -                
    -                let tlx = field.shape.x() - field.shape.offsetX();
    -                let tly = field.shape.y() - field.shape.offsetY();
    -                let tl = this.rotatePoint({x: tlx, y: tly}, {x: field.shape.x(), y: field.shape.y()}, field.shape.rotation());
    -                $('#debugpropgrid').jqPropertyGrid('set', {
    -                    'type': type,
    -                    'fieldId': field.shape.id(),
    -                    'x': 'sx: ' + (field.shape.x() | 0).toString() + ', ix: ' + ((field.shape.x() / scaleX) | 0).toString() ,
    -                    'y': 'sy: ' + (field.shape.y() | 0).toString() + ', iy: ' + ((field.shape.y() / scaleY) | 0).toString() ,
    -                    'tlx': (tl.x | 0).toString(),
    -                    'tly': (tl.y | 0).toString(),
    -                    'offsetx': (field.shape.offsetX() | 0).toString(),
    -                    'offsety': (field.shape.offsetY() | 0).toString(),
    -                    'width': 'sx: ' + (field.shape.width() | 0).toString() + ', ix: ' + ((field.shape.width() / scaleX) | 0).toString(),
    -                    'height': 'sy: ' + (field.shape.height() | 0).toString() + ', iy: ' + ((field.shape.height() / scaleY) | 0).toString(),
    -                    'rect': rectString,
    -                    'rectc' : rectCalc
    -                });
    -            }
    -        }
    -    }
    -
    -    updateDebugWindowMousePos(x, y) {
    -        if (this.#debugPosMode) {        
    -            let imageX = x  / this.#oeEditorStage.scaleX();
    -            let imageY = y  / this.#oeEditorStage.scaleY();        
    -            $('#debugpropgrid').jqPropertyGrid('set', {
    -                'mouseScreenx': 'sx: ' + (x | 0).toString() + ', ix: ' + (imageX | 0).toString(),
    -                'mouseScreeny': 'sy: ' + (y | 0).toString() + ', iy: ' + (imageY | 0).toString()
    -            });
    -        }
    -    }
    -
    -    #createDebugWindow() {
    -        let debugData = {
    -            type: '',
    -            fieldId: '',
    -            x: 0,
    -            y: 0,
    -            tlx: 0,
    -            tly: 0,
    -            offsetx: 0,
    -            offsety: 0,              
    -            width: 0,
    -            height: 0,
    -            mouseScreenx: 0,
    -            mouseScreeny: 0,
    -            rect: 0,
    -            rectc: 0  
    -        };
    -
    -        let debugConfig = {
    -            type: { group: 'Field', name: 'Type', type: 'text', options: {} },
    -            fieldId: { group: 'Field', name: 'Id', type: 'text', options: {} },
    -
    -            x: { group: 'Position', name: 'x', type: 'text' },
    -            y: { group: 'Position', name: 'y', type: 'text' },
    -            tlx: { group: 'Position', name: 'tlx', type: 'text' },
    -            tly: { group: 'Position', name: 'tly', type: 'text' },                        
    -            offsetx: { group: 'Position', name: 'offset x', type: 'text' },
    -            offsety: { group: 'Position', name: 'offset y', type: 'text' },                
    -            width: { group: 'Position', name: 'width', type: 'text' },
    -            height: { group: 'Position', name: 'height', type: 'text' },
    -
    -            rect: { group: 'Field Rect', name: 'Rect', type: 'text' },
    -            rectc: { group: 'Field Rect', name: 'Rect', type: 'text' },
    -
    -            mouseScreenx: { group: 'Mouse', name: 'Screen x', type: 'text' },
    -            mouseScreeny: { group: 'Mouse', name: 'Screen y', type: 'text' }
    -           
    -        }; 
    -        
    -        var options = {
    -            meta: debugConfig,
    -            prefix: 'debug',
    -            helpHtml: '[?]'
    -        };
    -
    -        $('#debugpropgrid').jqPropertyGrid(debugData, options);
    -        $('#debugdialog').dialog({
    -            resizable: false,
    -            closeOnEscape: false,
    -        });
    -    }
    -
    -    #createFormatHelpWindow() {
    -        $('#formatlisttable').DataTable().destroy();
    -        $('#formatlisttable').removeClass('hidden');
    -        $(document).off('click', '.oe-format-replace');
    -        $(document).off('click', '.oe-format-add');
    -        $('#formatlisttable').DataTable({
    -            ajax: {
    -                url: "includes/overlayutil.php?request=Formats",
    -                contentType: "application/json; charset=utf-8",
    -                dataType: "json",
    -            },
    -            pagingType: 'simple_numbers',
    -            paging: true,
    -            info: true,
    -            autoWidth: false,
    -            aaSorting: [],
    -            searchPanes: {
    -                controls: false              
    -            },
    -            dom: 'Plfrtip',                        
    -            columns: [
    -                { 
    -                    data: 'format',
    -                    width: '200px'
    -                },
    -                { 
    -                    data: 'description',
    -                    width: '400px'
    -                },
    -                { 
    -                    data: 'example',
    -                    width: '200px'
    -                },
    -                { 
    -                    data: 'type',
    -                    width: '0px',
    -                    visible: false
    -                },
    -                {
    -                    data: null,
    -                    width: '50px',
    -                    render: function (item, type, row, meta) {
    -                        let buttonReplace = '<button type="button" title="Replace Format" class="btn btn-primary btn-xs oe-format-replace" data-format="' + item.format + '"><i class="fa-solid fa-right-to-bracket"></i></button>';
    -                        let buttonAdd = '<button type="button" title="Add to format" class="btn btn-primary btn-xs oe-format-add" data-format="' + item.format + '"><i class="fa-solid fa-plus"></i></button>';
    -                        
    -                        let buttons = '<div class="btn-group">' + buttonReplace + buttonAdd + '</div>';                        
    -                        return buttons;
    -                    }
    -                }                
    -            ]
    -        });
    -      
    -        $('#formatdialog').dialog({
    -            resizable: false,
    -            closeOnEscape: false,
    -            width: 900
    -        });
    -        
    -        $(document).on('click', '.oe-format-replace', (event) => {
    -            let uiManager = window.oedi.get('uimanager');          
    -            let format = $(event.currentTarget).data('format');
    -            uiManager.updateFormat(format, 'replace');
    -        });
    -
    -        $(document).on('click', '.oe-format-add', (event) => {
    -            let uiManager = window.oedi.get('uimanager');
    -            let format = $(event.currentTarget).data('format');
    -            uiManager.updateFormat(format, 'add');
    -        })
    -    }
    -
    -    updateFormat(format, type) {
    -        let uiManager = window.oedi.get('uimanager');
    -        let field = uiManager.selected;
    -
    -        if (type == 'replace') {
    -            field.format = format;
    -        } else {
    -            field.format = field.format + format;
    -        }
    -
    -        uiManager.updatePropertyEditor();
    -        if ($('#oe-test-mode').hasClass('pulse')) {
    -            let fieldManager = window.oedi.get('fieldmanager');
    -            fieldManager.enableTestMode();
    -        }
    -    }
    -}
    \ No newline at end of file
    diff --git a/html/js/overlay/fields/oe-field.js b/html/js/overlay/fields/oe-field.js
    index ebacdb687..7a3f9ede5 100644
    --- a/html/js/overlay/fields/oe-field.js
    +++ b/html/js/overlay/fields/oe-field.js
    @@ -6,7 +6,8 @@ class OEFIELD {
       #dirty = false;
       shape = null;
       id = null;
    -
    +  loaded = true;
    +  
       OVERLAYFIELDSELECTOR = ".overlayfield";
     
       constructor(type, id) {
    @@ -46,6 +47,10 @@ class OEFIELD {
         }
       }
     
    +  get loaded() {
    +    return this.loaded;
    +  }
    +
       get id() {
         return this.id;
       }
    @@ -170,6 +175,15 @@ class OEFIELD {
         this.dirty = true;
       }
     
    +  get tlx() {
    +    return this.fieldData.tlx;
    +  }
    +
    +  get tly() {
    +    return this.fieldData.tly;
    +  }
    +
    +
       get rotation() {
         return this.fieldData.rotate;
       }
    diff --git a/html/js/overlay/fields/oe-fieldmanager.js b/html/js/overlay/fields/oe-fieldmanager.js
    index 7f4c15303..ea7a699e5 100644
    --- a/html/js/overlay/fields/oe-fieldmanager.js
    +++ b/html/js/overlay/fields/oe-fieldmanager.js
    @@ -35,11 +35,13 @@ class OEFIELDMANAGER {
             this.#fieldDeletedAddedDefaultsChanged = false;
         }
     
    -    async parseConfig() {
    +    parseConfig() {
    +        this.#fields = new Map();
             let config = window.oedi.get('config');
             let fields = config.getValue('fields', {});
             for (let index in fields) {
                 let newField = new OETEXTFIELD(fields[index], this.#idcounter++);
    +            newField.dirty = false;
                 fields[index].id = newField.id;
                 this.#fields.set(newField.id, newField);
             }
    @@ -47,6 +49,7 @@ class OEFIELDMANAGER {
             fields = config.getValue('images', {});
             for (let index in fields) {
                 let newField = new OEIMAGEFIELD(fields[index], this.#idcounter++);
    +            newField.dirty = false;
                 fields[index].id = newField.id;
                 this.#fields.set(newField.id, newField);
             }
    @@ -226,6 +229,7 @@ class OEFIELDMANAGER {
             }).done((data) => {
                 $.LoadingOverlay('hide');
                 clearTimeout(loadingTimer);
    +// console.log("data", data);
                 if (data.result === "OK") {
                     for (let key in data.fields) {
                         let field = this.findField(key);
    @@ -235,16 +239,22 @@ class OEFIELDMANAGER {
                     }
                 } else {
                     this.disableTestMode();
    -                bootbox.alert('Error generating sample data. Please ensure the overlay module is enabled');
    +                var msg;
    +                if (data.result === "LEGACY_MODE") {
    +                    msg = 'The WebUI "Overlay Method" setting is set to "legacy" so overlays will not work.';
    +                } else if (data.result === "FILE_MISSING") {
    +                    msg = 'Error generating sample data. Missing file: ' + data.missingFile + ',  Try later';
    +                } else {    // should be data.result == "ERROR"
    +                    msg = 'Error generating sample data: ' + data.error;
    +                }
    +                bootbox.alert(msg);
                 }
             }).fail((jqXHR, textStatus, errorThrown) => {
    +            console.log("in .fail:  errorThrown=", errorThrown, ", jqXHR=", jqXHR);
             }).always(() => {
                 clearTimeout(loadingTimer);
                 $.LoadingOverlay('hide');
             });
    -
    -
    -
         }
     
         disableTestMode() {
    @@ -259,4 +269,4 @@ class OEFIELDMANAGER {
             }
         }
     
    -}
    \ No newline at end of file
    +}
    diff --git a/html/js/overlay/fields/oe-image.js b/html/js/overlay/fields/oe-image.js
    index c3f7032fd..208cd598c 100644
    --- a/html/js/overlay/fields/oe-image.js
    +++ b/html/js/overlay/fields/oe-image.js
    @@ -29,6 +29,7 @@ class OEIMAGEFIELD extends OEFIELD {
         super('images', id);
         this.config = window.oedi.get('config');
         this.fieldData = fieldData;
    +    this.loaded = false;
     
         this.setDefaults();
     
    @@ -53,6 +54,7 @@ class OEIMAGEFIELD extends OEFIELD {
         this.setImage(this.fieldData.image).then((imageObj) => {
           this.shape.image(imageObj);      
           this.dirty = false;
    +      this.loaded = true;
         });
     
     
    diff --git a/html/js/overlay/oe-config.js b/html/js/overlay/oe-config.js
    index a6111ff70..fe935f83d 100644
    --- a/html/js/overlay/oe-config.js
    +++ b/html/js/overlay/oe-config.js
    @@ -5,13 +5,37 @@ class OECONFIG {
         #appConfig = {};
         #dataFields = {};
         #overlayDataFields = {};
    +    #selectedOverlay = {
    +        type: null,
    +        name: null
    +    };
         #BASEDIR = 'annotater/';
    +    #dirty = false;
    +    #overlays = {};
     
         #lastConfig = [];
     
         constructor() {
         }
     
    +    get selectedOverlay() {
    +        return this.#selectedOverlay;
    +    }
    +
    +    get overlays() {
    +        return this.#overlays;
    +    }
    +    set overlays(value) {
    +        this.#overlays = value;
    +    }
    +
    +    get dirty() {
    +        return this.#dirty;
    +    }
    +    set dirty(value) {
    +        this.#dirty = value;
    +    }
    +
         get config() {
             return this.#config;
         }
    @@ -19,66 +43,124 @@ class OECONFIG {
             this.#config = config;
         }
     
    +    
         get appConfig() {
             return this.#appConfig;
         }
     
    -    /**
    -     * 
    -     * Loads a configuration(s)
    -     * 
    -     * @returns 
    -     */
    -    async loadConfig() {
    -        let result;
    +    loadOverlays() {
    +        $.ajax({
    +            url: 'includes/overlayutil.php?request=Overlays',
    +            type: 'GET',
    +            dataType: 'json',
    +            cache: false,
    +            async: false,                
    +            context: this
    +        }).done((overlays) => {
    +            this.overlays = overlays;
    +        }); 
    +    }
     
    +    loadConfig() {
             try {
    -            result = await $.ajax({
    +            let result = $.ajax({
                     type: "GET",
    -                url: "includes/overlayutil.php?request=Config",
    +                url: "includes/overlayutil.php?request=Configs",
                     data: "",
                     dataType: 'json',
    -                cache: false
    +                cache: false,
    +                async: false,
    +                context: this,
    +                success: function (result) {
    +                    this.#config = result.config
    +                    this.#dataFields = result.data;
    +                    this.#overlayDataFields = result.overlaydata;
    +                    this.#appConfig = result.appconfig;
    +                }                
                 });
    +        } catch (error) {
    +            confirm('A fatal error has occureed loading the application configuration.')
    +            return false;
    +        }            
    +    }
     
    -            this.#config = result;
    -            if (this.validateConfig()) {
    -
    -                result = await $.ajax({
    -                    type: "GET",
    -                    url: "includes/overlayutil.php?request=Data",
    -                    data: "",
    -                    dataType: 'json',
    -                    cache: false
    -                });
    -                this.#dataFields = result;
    -
    -                result = await $.ajax({
    -                    type: "GET",
    -                    url: "includes/overlayutil.php?request=OverlayData",
    -                    data: "",
    -                    dataType: 'json',
    -                    cache: false
    -                });
    -                this.#overlayDataFields = result;
    -
    -                this.#appConfig = await $.ajax({
    -                    type: "GET",
    -                    url: "includes/overlayutil.php?request=AppConfig",
    -                    data: "",
    -                    dataType: 'json',
    -                    cache: false
    -                });
    -
    -                return true;
    -            } else {
    -                return false;
    -            }
    -
    +    loadOverlay(overlay, type) {
    +        this.#selectedOverlay.type = type;
    +        this.#selectedOverlay.name = overlay;
    +        try {
    +            let result = $.ajax({
    +                type: "GET",
    +                url: "includes/overlayutil.php?request=LoadOverlay&overlay=" + overlay,
    +                data: "",
    +                dataType: 'json',
    +                cache: false,
    +                async: false,
    +                context: this,
    +                success: function (result) {    
    +                    this.#config = result;
    +  
    +                    let fontList = Array.from(document.fonts);
    +                    for (let i in fontList) {
    +                        document.fonts.delete(fontList[i]);
    +                    }
    +
    +                    const promises = [];
    +                    let fonts = this.getValue('fonts', {});
    +                    for (let font in fonts) {
    +                        let fontData = this.getValue('fonts.' + font, {});
    +                        let fontFace = new FontFace(font, 'url(' + window.oedi.get('BASEDIR') + fontData.fontPath + ')');
    +                        promises.push(
    +                            fontFace.load()
    +                        );
    +                    }
    +
    +                    Promise.all(promises).then(function(loadedFonts) {
    +                        for (let font in loadedFonts) {
    +                            document.fonts.add(loadedFonts[font]);
    +                        }
    +                        window.oedi.get('uimanager').buildUI();
    +                    });
    +
    +                    $(document).trigger('oe-overlay-loaded', {
    +                        overlay: this.#selectedOverlay
    +                    });
    +                    this.dirty = false;                
    +                },
    +                error: function(xHR, Status, error) {
    +                    if (xHR.responseText.length === 0) {
    +                        /**
    +                         * Something has gone badly wrong - The active overlay doesnt exist so we will set the active
    +                         * overlay to the defaulf for the camera then redirect the user back to the overlay manager
    +                         * which will hopefully fix that issue
    +                         */
    +                        alert(' The active overlay does not exist, was it deleted? The active overlay will be reset to the default for your camera.\n\nPlease click OK to continue');
    +                        
    +                        let defaultOverlay = 'overlay-ZWO.json';
    +                        if (this.overlays !== undefined) {
    +                            if (this.overlays.brand !== undefined) {
    +                                defaultOverlay = 'overlay-' + this.overlays.brand + '.json';
    +                            }
    +                        }
    +
    +                        let result = $.ajax({
    +                            type: 'POST',
    +                            url: 'includes/overlayutil.php?request=SaveSettings',
    +                            data: {
    +                                daytime: defaultOverlay,
    +                                nighttime: defaultOverlay
    +                            },
    +                            dataType: 'json',
    +                            cache: false,
    +                            async: false
    +                        });
    +                        location.reload();
    +                    }
    +                }                
    +            });
             } catch (error) {
                 confirm('A fatal error has occureed loading the application configuration.')
                 return false;
    -        }
    +        }         
         }
     
         get gridVisible() {
    @@ -166,6 +248,39 @@ class OECONFIG {
             return this.#dataFields = dataFields;
         }
     
    +    get overlayErrors() {
    +        return this.#appConfig.overlayErrors;
    +    }
    +    set overlayErrors(overlayErrors) {
    +        return this.#appConfig.overlayErrors = overlayErrors;
    +    }
    +
    +    get overlayErrorsText() {
    +        return this.#appConfig.overlayErrorsText;
    +    }
    +    set overlayErrorsText(overlayErrorsText) {
    +        return this.#appConfig.overlayErrorsText = overlayErrorsText;
    +    }
    +
    +    getMetaField(field) {
    +        let result = null;
    +
    +        if (this.#config.metadata !== undefined) {
    +            if (this.#config.metadata[field] !== undefined) {
    +                result = this.#config.metadata[field];
    +            }
    +        }
    +        return result;
    +    }
    +
    +    setMetaField(field, value) {
    +        if (this.#config.metadata === undefined) {
    +            this.#config.metadata = {};
    +        }
    +        this.#config.metadata[field] = value;
    +        this.dirty = true;
    +    }
    +
         backupConfig() {
             this.#lastConfig= JSON.parse(JSON.stringify(this.#config));
         }
    @@ -258,21 +373,27 @@ class OECONFIG {
             }
         }
     
    -    async saveConfig() {
    -        result = await $.ajax({
    +    saveConfig() {
    +        debugger;
    +        /*
    +        result =  $.ajax({
                 type: 'POST',
                 url: 'includes/overlayutil.php?request=Config',
                 data: { config: JSON.stringify(this.#config) },
    +            async: false,
                 dataType: 'json',
                 cache: false
    -        });
    +        });*/
         }
     
         saveConfig1() {
             $.ajax({
                 type: 'POST',
                 url: 'includes/overlayutil.php?request=Config',
    -            data: { config: JSON.stringify(this.#config) },
    +            data: {
    +                overlay: this.#selectedOverlay,
    +                config: JSON.stringify(this.#config)
    +            },
                 cache: false
             }).done(function() {
             }).fail(function() {
    diff --git a/html/js/overlay/oe-overlayeditor.js b/html/js/overlay/oe-overlayeditor.js
    index 725b946a1..8a35366e9 100644
    --- a/html/js/overlay/oe-overlayeditor.js
    +++ b/html/js/overlay/oe-overlayeditor.js
    @@ -14,32 +14,43 @@ class OVERLAYEDITOR {
             let uiManager = new OEUIMANAGER(image);
             window.oedi.add('uimanager', uiManager);
             window.oedi.add('BASEDIR', 'overlay/');    
    -        window.oedi.add('IMAGEDIR', 'overlay/images/');       
    +        window.oedi.add('IMAGEDIR', 'overlay/images/');
         }
     
    -    async #loadFonts() {
    -        let config = window.oedi.get('config');
    -        let fonts = config.getValue('fonts', {});
    -        for (let font in fonts) {
    -            let fontData = config.getValue('fonts.' + font, {});
    +    buildUI() {
    +        //this.#checkResolution();
    +        $.LoadingOverlay('show');
     
    -            let fontFace = new FontFace(font, 'url(' + window.oedi.get('BASEDIR') + fontData.fontPath + ')');
    -            await fontFace.load();
    -            document.fonts.add(fontFace);
    -        }
    -    }
    +        $.ajax({
    +            url: 'includes/overlayutil.php?request=Overlays',
    +            type: 'GET',
    +            dataType: 'json',
    +            cache: false,
    +            async: false,                
    +            context: this
    +        }).done((overlays) => {
    +            let configManager = window.oedi.get('config');
    +            configManager.overlays = overlays;
     
    -    async buildUI() {
    -        $.LoadingOverlay('show');
    -        if (await window.oedi.get('config').loadConfig()) {
    -            await this.#loadFonts();
    +            $('#oe-overlay-manager').allskyMM();
     
    -            window.oedi.get('fieldmanager').parseConfig();
    -            window.oedi.get('uimanager').buildUI();
    -            $.LoadingOverlay('hide');
    -        }        
    +            configManager.loadConfig();
    +            $(document).trigger('oe-startup');
    +            $.LoadingOverlay('hide');            
    +        }); 
         }
     
    +    #checkResolution() {
    +        var width = window.innerWidth;
    +        var height = window.innerHeight;
    +
    +        if (width < 1024 || height < 768) {
    +            bootbox.alert({
    +                title: 'Overlay Editor Warning',
    +                message: 'Your screen resolution (' + width + 'x' + height + ') is below the recommened resolution (1024x768) for the overlay editor. You may continue to use the overlay editor but some functions may not be useable.'
    +            });
    +        }
    +    }
         /**
          * A DI container for the overlay editor. This reduces a lot of issues with scope in 3rd party
          * libraries.
    @@ -57,4 +68,4 @@ class OVERLAYEDITOR {
     
         }
     
    -}
    \ No newline at end of file
    +}
    diff --git a/html/js/overlay/oe-uimanager.js b/html/js/overlay/oe-uimanager.js
    index 1f9813b30..2fa9d2927 100644
    --- a/html/js/overlay/oe-uimanager.js
    +++ b/html/js/overlay/oe-uimanager.js
    @@ -20,6 +20,8 @@ class OEUIMANAGER {
         #stageScale = 0.6;
         #stageMode = 'fit';   
         #imageCache = null;
    +    #errorFields = [];
    +    #errorsTable = null;
     
         #fieldTable = null;
         #allFieldTable = null;
    @@ -60,27 +62,6 @@ class OEUIMANAGER {
             this.#oeEditorStage.add(this.#overlayLayer);
             this.#oeEditorStage.add(this.#gridLayer);
     
    -        this.#transformer = new Konva.Transformer({
    -            resizeEnabled: false
    -        });
    -        this.#overlayLayer.add(this.#transformer);
    -
    -        this.setZoom('oe-zoom-fit');
    -
    -        this.#snapRectangle = new Konva.Rect({
    -            x: 0,
    -            y: 0,
    -            name: 'snapRectangle',
    -            width: 100,
    -            height: 50,
    -            fill: '#cccccc',
    -            opacity: 0.6,
    -            stroke: '#333',
    -            strokeWidth: 1,
    -            visible: false
    -        });
    -        this.#overlayLayer.add(this.#snapRectangle);
    -
             this.#oeEditorStage.on('mousemove', (e) => {
                 let mousePos = this.#oeEditorStage.getPointerPosition();
                 this.updateDebugWindowMousePos(mousePos.x, mousePos.y);
    @@ -91,16 +72,22 @@ class OEUIMANAGER {
     
             if (params.hasOwnProperty('debug')) {
                 if (params.debug == 'true') {
    -                this.#debugMode = true;
    +                localStorage.setItem('debugMode', 'true');
    +            } else {
    +                localStorage.setItem('debugMode', 'false');
                 }
             }
     
             if (params.hasOwnProperty('debugpos')) {
                 if (params.debugpos == 'true') {
    -                this.#debugPosMode = true;
    +                localStorage.setItem('debugpos', 'true');
    +            } else {
    +                localStorage.setItem('debugpos', 'false');
                 }
             }
     
    +        this.#debugMode = localStorage.getItem('debugMode') === 'true' ? true: false;
    +        this.#debugPosMode = localStorage.getItem('debugpos') === 'true' ? true: false;
         }
     
         getQueryParams(url) {
    @@ -113,6 +100,15 @@ class OEUIMANAGER {
             return params;
         }
     
    +    get dirty() {
    +        let result = false;
    +        if (this.#fieldManager.dirty || this.#configManager.dirty) {
    +            result = true;
    +        }
    +
    +        return result;
    +    }
    +
         get selected() {
             return this.#selected;
         }
    @@ -141,9 +137,89 @@ class OEUIMANAGER {
             }
         }
     
    +    resetUI() {
    +
    +        $(window).off('resize');
    +        this.#oeEditorStage.off('dragmove')
    +        this.#oeEditorStage.off('wheel');
    +        this.#oeEditorStage.off('click tap');
    +        this.#overlayLayer.off('dblclick dbltap');
    +        this.#overlayLayer.off('dragstart');
    +        this.#overlayLayer.off('dragmove');
    +        this.#overlayLayer.off('dragend');
    +    
    +        $(document).off('dblclick', '.draggable');
    +        $(document).off('click', '#oe-item-list');
    +        $(document).off('click', '.oe-list-delete');
    +        $(document).off('click', '.oe-all-list-add');
    +        $(document).off('click', '.oe-list-add');
    +        $(document).off('click', '#oe-field-dialog-add-field');
    +        $(document).off('click', '.oe-list-edit');
    +        $(document).off('click', '#oe-field-save');
    +        $(document).off('click', '#oe-item-list-dialog-save');
    +        $(document).off('click', '#oe-item-list-dialog-close');
    +        $(document).off('click', '#oe-save');
    +        $(document).off('click', '#oe-test-mode');
    +        $(document).off('click', '#oe-delete');
    +        $(document).off('click', '#oe-add-text');
    +        $(document).off('click', '#oe-add-image');
    +        $(document).off('click', '#oe-options');
    +        $('#optionsdialog a[data-toggle="tab"]').off('shown.bs.tab');
    +        $(document).off('click', '#optionsdialognewoverlay');
    +        $(document).off('click', '.oe-options-overlay-edit');
    +        $(document).off('click', '#oe-defaults-save');
    +        $(document).off('click', '#oe-font-dialog-add-font');
    +        $(document).off('click', '#oe-font-dialog-upload-font');
    +        $(document).off('click', '#oe-upload-font');
    +        $('#fontlisttable').off('click', '.oe-list-font-use');
    +        $('#fontlisttable').off('click', '.oe-list-font-remove');
    +        $(document).off('click', '.oe-list-font-delete');
    +        $(document).off('click', '.oe-zoom');
    +        $(document).off('click', '#oe-show-image-manager');
    +        $(document).off('oe-imagemanager-add');
    +        $(document).off('click', '#oe-toobar-debug-button');
    +        $('.modal').off('shown.bs.modal');
    +        $(window).off('resize');
    +        $(document).off('click', '#oe-field-errors');
    +        $(document).off('click', '#oe-field-errors-dialog-close');
    +        $(document).off('click', '.oe-field-errors-dialog-delete');
    +        $(document).off('click', '.oe-field-errors-dialog-fix');
    +        $(document).off('oe-config-updated');
    +        $(document).off('click','#oe-show-overlay-manager');
    +
    +        this.#overlayLayer.destroyChildren();
    +        this.#transformer = new Konva.Transformer({
    +            resizeEnabled: false
    +        });
    +        this.#overlayLayer.add(this.#transformer);        
    +
    +        this.setZoom('oe-zoom-fit');
    +
    +        this.#snapRectangle = new Konva.Rect({
    +            x: 0,
    +            y: 0,
    +            name: 'snapRectangle',
    +            width: 100,
    +            height: 50,
    +            fill: '#cccccc',
    +            opacity: 0.6,
    +            stroke: '#333',
    +            strokeWidth: 1,
    +            visible: false
    +        });
    +        this.#overlayLayer.add(this.#snapRectangle);
    +
    +        this.#transformer.off('transformend');
    +
    +
    +    }
    +
         buildUI() {
    +        this.resetUI();
             this.setupFonts();
     
    +        window.oedi.get('fieldmanager').parseConfig();
    +
             let fields = this.#fieldManager.fields;
             for (let [fieldName, field] of fields.entries()) {
                 let object = field.shape;
    @@ -155,8 +231,19 @@ class OEUIMANAGER {
                 this.#resizeWindow();
             });
     
    +        if (!this.#debugMode) {
    +            let selectedOverlay = this.#configManager.selectedOverlay;
    +            if (selectedOverlay.type === 'allsky') {
    +                $('#oe-overlay-disable').removeClass('hidden');
    +            } else {
    +                $('#oe-overlay-disable').addClass('hidden');
    +            }
    +        } else {
    +            $('#oe-overlay-disable').addClass('hidden');
    +        }
    +
             jQuery(window).bind('beforeunload', ()=> {
    -            if (this.#fieldManager.dirty) {
    +            if (this.#fieldManager.dirty || this.#configManager.dirty) {
                     return ' ';
                 } else {
                     return undefined;
    @@ -328,6 +415,7 @@ class OEUIMANAGER {
                     this.#snapRectangle.visible(false);
                 }
                 this.#movingField = null;
    +            this.checkFields();
                 this.updateToolbar();
             });
     
    @@ -366,6 +454,7 @@ class OEUIMANAGER {
     
                 this.#fieldTable = $('#itemlisttable').DataTable({
                     data: this.#configManager.dataFields,
    +                retrieve: true,
                     autoWidth: false,
                     pagingType: 'simple_numbers',
                     paging: true,
    @@ -426,6 +515,7 @@ class OEUIMANAGER {
                     $('#oe-item-list-dialog-all-error').hide();
                     this.#allFieldTable = $('#allitemlisttable').DataTable({
                         data: this.#configManager.allDataFields,
    +                    retrieve: true,
                         autoWidth: false,
                         pagingType: 'simple_numbers',
                         paging: true,
    @@ -768,8 +858,9 @@ class OEUIMANAGER {
             });
     
             $(document).on('click', '#oe-save', (event) => {
    -            if (this.#fieldManager.dirty) {
    +            if (this.#fieldManager.dirty || this.#configManager.dirty) {
                     this.#saveConfig();
    +                $(document).trigger('oe-overlay-saved');
                 }
             });
     
    @@ -799,6 +890,8 @@ class OEUIMANAGER {
             });
     
             $(document).on('click', '#oe-add-text', (event) => {
    +            event.stopPropagation();
    +            event.preventDefault();
                 let shape = this.#fieldManager.addField('text');
                 this.#overlayLayer.add(shape);
     
    @@ -876,6 +969,9 @@ class OEUIMANAGER {
                 $('#oe-app-options-background-opacity').val(this.#configManager.backgroundImageOpacity);
                 $('#oe-app-options-grid-colour').val(this.#configManager.gridColour);
     
    +            $('#oe-app-options-show-errors').prop('checked', this.#configManager.overlayErrors);
    +            $('#oe-app-options-show-errors-text').val(this.#configManager.overlayErrorsText);
    +
                 $('#oe-app-options-grid-colour').spectrum({
                     type: 'color',
                     showInput: true,
    @@ -884,6 +980,54 @@ class OEUIMANAGER {
                     preferredFormat: 'hex'
                 });            
                 
    +
    +            $('#overlaytablelist').DataTable().destroy();
    +            $('#overlaytablelist').DataTable({
    +                ajax: 'includes/overlayutil.php?request=OverlayList',
    +                dom: '<"toolbar">frtip',
    +                autoWidth: false,
    +                pagingType: 'simple_numbers',
    +                paging: true,
    +                pageLength: 20,
    +                info: false,
    +                searching: false,
    +                order: [[0, 'asc']],
    +                ordering: false,
    +                columns: [
    +                    {
    +                        data: 'type',
    +                        width: '80px'
    +                    }, {
    +                        data: 'name',
    +                        width: '300px'
    +                    }, {
    +                        data: 'brand',
    +                        width: '80px'
    +                    }, {
    +                        data: 'model',
    +                        width: '80px'
    +                    }, {
    +                        data: 'tod',
    +                        width: '80px',
    +                        render: function (item, type, row, meta) {
    +                            return item.charAt(0).toUpperCase() + item.slice(1);
    +                        }                        
    +                    }, {
    +                        data: null,
    +                        width: '50px',
    +                        render: function (item, type, row, meta) {
    +                            let icon = 'fa-pen-to-square'
    +                            if (item.type === 'Allsky') {
    +                                icon = 'fa-file-circle-plus';
    +                            }
    +
    +                            let buttons = '<button type="button" class="btn btn-primary btn-sms oe-options-overlay-edit" data-filename="' + item.filename + '"><i class="fa-solid ' + icon + '"></i></button>';
    +                            return buttons;
    +                        }
    +                    }
    +                ]
    +            });
    +
                 $('#optionsdialog').modal({
                     keyboard: false
                 });
    @@ -892,6 +1036,28 @@ class OEUIMANAGER {
     
             });
     
    +        $('#optionsdialog a[data-toggle="tab"]').on('shown.bs.tab', function (e) {
    +            var target = $(e.target).attr('href')
    +            if (target === '#oeeditoroverlays') {
    +                $('#optionsdialognewoverlay').removeClass('hidden');
    +            } else {
    +                $('#optionsdialognewoverlay').addClass('hidden');
    +            }
    +        });
    +
    +        $(document).on('click', '#optionsdialognewoverlay', (event) => {
    +            $('#oe-overlay-manager').data('allskyMM').show();
    +            $('#oe-overlay-manager').data('allskyMM').showNew();            
    +            $('#optionsdialog').modal('hide');
    +        });
    +
    +        $(document).on('click', '.oe-options-overlay-edit', (event) => {
    +            let fileName = $(event.currentTarget).data('filename');
    +            $('#oe-overlay-manager').data('allskyMM').show();
    +            $('#oe-overlay-manager').data('allskyMM').setSelected(fileName);
    +            $('#optionsdialog').modal('hide');
    +        });
    +
             $(document).on('click', '#oe-defaults-save', (event) => {
                 let defaultImagOpacity = $('#defaultimagetopacity').val() / 100;
                 let defaultImagRotation = $('#defaultimagerotation').val() | 0;
    @@ -909,7 +1075,6 @@ class OEUIMANAGER {
                 let defaultStrokeColour = $('#oe-default-stroke-colour').val();
                 let defaultStrokeSize = $('#oe-default-stroke-size').val();
     
    -            this.#configManager.backupConfig();
                 this.#configManager.setValue('settings.defaultimagetopacity', defaultImagOpacity);
                 this.#configManager.setValue('settings.defaultimagerotation', defaultImagRotation);
                 this.#configManager.setValue('settings.defaultfontsize', defaultFontSize);
    @@ -925,7 +1090,6 @@ class OEUIMANAGER {
                 this.#configManager.setValue('settings.defaultincludesun', defaultincludesun);
                 this.#configManager.setValue('settings.defaultstrokecolour', defaultStrokeColour);
     
    -
                 this.#configManager.gridVisible = $('#oe-app-options-show-grid').prop('checked');
                 this.#configManager.gridSize = $("#oe-app-options-grid-size option").filter(":selected").val();
                 this.#configManager.gridOpacity = $('#oe-app-options-grid-opacity').val() | 0;
    @@ -937,6 +1101,9 @@ class OEUIMANAGER {
                 this.#configManager.mouseWheelZoom = $('#oe-app-options-mousewheel-zoom').prop('checked');
                 this.#configManager.backgroundImageOpacity = $('#oe-app-options-background-opacity').val() | 0;
     
    +            this.#configManager.overlayErrors = $('#oe-app-options-show-error').prop('checked');
    +            this.#configManager.overlayErrorsText = $('#oe-app-options-show-error').val();
    +
                 this.#fieldManager.updateFieldDefaults();
                 this.drawGrid();
                 this.updateBackgroundImage();
    @@ -986,10 +1153,10 @@ class OEUIMANAGER {
                     columns: [
                         {
                             data: 'name',
    -                        width: '150px'
    +                        width: '250px'
                         }, {
                             data: 'path',
    -                        width: '150px',
    +                        width: '250px',
                             render: function (item, type, row, meta) {
                                 if (item.includes('msttcorefonts')) {
                                     return 'System Font';
    @@ -1007,8 +1174,30 @@ class OEUIMANAGER {
     
                                 let buttons = '';
                                 if (item.name !== 'moon_phases' && item.name !== defaultFont && !item.path.includes('msttcorefonts')) {
    -                                buttons += '&nbsp; <button type="button" class="btn btn-danger btn-xs oe-list-font-delete" data-fontname="' + item.name + '"><i class="fa-solid fa-trash"></i></button>';
    -                                buttons += '</div>';
    +
    +
    +                                let fonts = config.getValue('fonts');
    +                                let extPos = item.name.indexOf('.');
    +    
    +                                let fontName = item.name;
    +                                if (extPos > -1) {
    +                                    let parts = item.name.split('.');
    +                                    fontName = parts[0];
    +                                }
    +                                let fontLCName = fontName.toLowerCase();
    +
    +                                let enabled = false;
    +                                if (fonts[fontLCName] !== undefined) {
    +                                    enabled = true;
    +                                }
    +
    +
    +                                buttons += '<button type="button" class="btn btn-danger btn-xs oe-list-font-delete" data-fontname="' + item.name + '"><i class="fa-solid fa-trash"></i></button>';
    +                                if (!enabled) {
    +                                    buttons += '&nbsp; <button type="button" class="btn btn-primary btn-xs oe-list-font-use" data-fontname="' + fontName + '" data-path="' + item.path + '">Use</button>';
    +                                } else {
    +                                    buttons += '&nbsp; <button type="button" class="btn btn-danger btn-xs oe-list-font-remove" data-fontname="' + fontName + '" data-path="' + item.path + '">Remove</button>';
    +                                }
                                 }
                                 return buttons;
                             }
    @@ -1023,6 +1212,43 @@ class OEUIMANAGER {
                 })
             });
     
    +        $('#fontlisttable').on('click', '.oe-list-font-use', function(e) {
    +            let fontName = $(e.currentTarget).data('fontname');
    +            let fontPath = $(e.currentTarget).data('path');
    +
    +            let fontFace = new FontFace(fontName, 'url(' + window.oedi.get('BASEDIR') + fontPath + ')');
    +            fontFace.load().then(function(font) {
    +                document.fonts.add(fontFace);
    +                this.setupFonts();
    +                this.#configManager.setValue('fonts.' + fontName.toLowerCase() + '.fontPath', fontPath);
    +                $('#fontlisttable').DataTable().ajax.reload( null, false ); 
    +                this.#configManager.dirty = true;
    +                this.updateToolbar();
    +            }.bind(this));
    +        }.bind(this));
    +
    +        $('#fontlisttable').on('click', '.oe-list-font-remove', function(e) {
    +            let fontName = $(e.currentTarget).data('fontname');
    +            let fontToDelete = null;
    +            for (let fontFace of document.fonts.values()) {
    +                if (fontFace.family == fontName) {
    +                    fontToDelete = fontFace;
    +                    break;
    +                }
    +            }
    +    
    +            if (fontToDelete !== null) {
    +                document.fonts.delete(fontToDelete);
    +            }
    +            
    +            this.#configManager.deleteValue('fonts.' + fontName.toLowerCase());
    +            this.#fieldManager.switchFontUsed(fontName);
    +            this.setupFonts();
    +            $('#fontlisttable').DataTable().ajax.reload( null, false );
    +            this.#configManager.dirty = true;
    +            this.updateToolbar();            
    +        }.bind(this));
    +
             $(document).on('click', '.oe-list-font-delete', (event) => {
                 event.stopPropagation();
                 if (window.confirm('Are you sure you wish to delete this font? If the font is in use then all fields will be set to the default font.')) {
    @@ -1030,6 +1256,8 @@ class OEUIMANAGER {
                     if (fontName !== 'undefined') {
                         let uiManager = window.oedi.get('uimanager');
                         uiManager.deleteFont(fontName);
    +                    this.#configManager.dirty = true;
    +                    this.updateToolbar();
                     }
                 }
             });
    @@ -1095,11 +1323,148 @@ class OEUIMANAGER {
     
             $('[data-toggle="tooltip"]').tooltip();
     
    +        $(document).on('click', '#oe-field-errors', (event) => {
    +
    +            this.#errorsTable = $('#fielderrorstable').DataTable({
    +                data: this.#errorFields,
    +                retrieve: true,
    +                autoWidth: false,
    +                pagingType: 'simple_numbers',
    +                paging: true,
    +                info: true,
    +                ordering: false,
    +                searching: true,
    +                rowId: 'id',
    +                pageLength: parseInt(this.#configManager.addListPageSize),
    +                lengthMenu: [ [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, -1], [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 'All']],
    +                order: [[0, 'asc']],
    +                columns: [
    +                    {
    +                        data: 'name',
    +                        width: '600px'
    +                    }, {
    +                        data: 'type',
    +                        width: '100px'
    +                    }, {
    +                        data: null,
    +                        width: '100px',
    +                        render: function (item, type, row, meta) {
    +                            let buttons = '';
    +                            buttons += '<button type="button" class="btn btn-primary btn-xs oe-field-errors-dialog-fix" data-id="' + item.id + '" data-type="' + item.type + '">Fix</button>';
    +                            buttons += '<button style="margin-left:10px;" type="button" class="btn btn-danger btn-xs oe-field-errors-dialog-delete" data-id="' + item.id + '"><i class="fa-solid fa-trash oe-field-errors-dialog-delete" data-id="' + item.id + '"></i></button>';
    +                            buttons += '</div>';
    +                            return buttons;
    +                        }
    +                    }
    +
    +                ],
    +                fnDrawCallback: function (oSettings) {
    +                    if (oSettings._iDisplayLength >= oSettings.aoData.length) {
    +                        $(oSettings.nTableWrapper).find('.dataTables_paginate').hide();
    +                        $(oSettings.nTableWrapper).children('div').first().hide();
    +                        $(oSettings.nTableWrapper).children('div').last().hide();
    +                    } else {
    +                        $(oSettings.nTableWrapper).find('.dataTables_paginate').show();
    +                        $(oSettings.nTableWrapper).children('div').first().show();
    +                        $(oSettings.nTableWrapper).children('div').last().show();
    +                    }
    +                }
    +            });
    +
    +            if ($('#oe-field-errors-dialog').data('bs.modal') === undefined) {
    +                $('#oe-field-errors-dialog').modal({
    +                    keyboard: false,
    +                    width: 800
    +                })
    +            } else {
    +                $('#oe-field-errors-dialog').modal('show');
    +            }
    +
    +            $('#oe-field-errors-dialog').on('hidden.bs.modal', (event) => {
    +                this.checkFields();
    +                $('#fielderrorstable').DataTable().destroy();
    +            });            
    +        });
    +
    +        $(document).on('click', '#oe-field-errors-dialog-close', (event) => {
    +            $('#oe-field-errors-dialog').modal('hide');            
    +        });
    +
    +        $(document).on('click', '.oe-field-errors-dialog-delete', (event) => {
    +            event.preventDefault();
    +            event.stopPropagation();
    +            let fieldId = $(event.currentTarget).data('id');
    +            let field = this.#fieldManager.findField(fieldId);
    +
    +            let shape = field.shape;
    +            this.#fieldManager.deleteField(shape.id());
    +            shape.destroy();
    +            this.#errorsTable.rows('#' + fieldId).remove().draw();
    +
    +            if (this.#errorsTable .rows().count() == 0) {
    +                $('#oe-field-errors-dialog').modal('hide');
    +            }    
    +
    +            this.#configManager.dirty = true;
    +            this.updateToolbar();            
    +        });
    +
    +        $(document).on('click', '.oe-field-errors-dialog-fix', (event) => {
    +            event.preventDefault();
    +            event.stopPropagation();
    +            let fieldId = $(event.currentTarget).data('id');
    +            let field = this.#fieldManager.findField(fieldId);
    +
    +            let stageWidth = this.#oeEditorStage.width();
    +            let stageHeight = this.#oeEditorStage.height();
    +            
    +            field.x = (stageWidth / 2)|0;
    +            field.y = (stageHeight / 3)|0;
    +
    +            this.#errorsTable.rows('#' + fieldId).remove().draw();
    +
    +            if (this.#errorsTable .rows().count() == 0) {
    +                $('#oe-field-errors-dialog').modal('hide');
    +            }
    +
    +            this.#configManager.dirty = true;
    +            this.updateToolbar();            
    +        });
    +             
    +        $(document).on('oe-config-updated', (e) => {
    +            this.updateToolbar();
    +        });
    +
    +        $(document).on('click','#oe-show-overlay-manager', (e) => {
    +            $(document).trigger('oe-show-overlay-manager');
    +        });
    +
             this.updateDebugWindow();
             this.drawGrid();
             this.updateBackgroundImage();
             this.setupDebug();
             this.updateToolbar();
    +        this.checkFieldstimer();
    +    }
    +
    +    checkFieldstimer() {
    +        let checkFunction = function() {
    +            let allLoaded = true;
    +            let fields = this.#fieldManager.fields;
    +            for (let [fieldName, field] of fields.entries()) {
    +                if (!field.loaded) {
    +                    allLoaded = false;
    +                    break;
    +                }
    +            }
    +            if (!allLoaded) {
    +                setTimeout(checkFunction, 100);
    +            } else {
    +                this.checkFields();
    +            }
    +        }.bind(this);
    +
    +        setTimeout(checkFunction, 100);
         }
     
         moveField(event) {
    @@ -1136,13 +1501,91 @@ class OEUIMANAGER {
             this.checkFieldBounds(shape, this.#oeEditorStage, this.#transformer);
         }
     
    -    checkFieldBounds(shape, oeEditorStage, transformer) {
    -        if (transformer.borderStroke() !== '#00a1ff') {
    -            transformer.borderStroke('#00a1ff');
    -            transformer.borderStrokeWidth(1);
    +    checkFields() {
    +        this.#errorFields = [];
    +        let fields = this.#fieldManager.fields;
    +        for (let [fieldName, field] of fields.entries()) {
    +  
    +            let result = this.isFieldOutsideViewport(field);
    +            if (result.outOfBounds) {
    +                let name = 'Unknown';
    +                if (field instanceof OEIMAGEFIELD) {
    +                    name = field.image;
    +                } else {
    +                    name = field.label;
    +                }
    +                this.#errorFields.push({
    +                    'id': fieldName,
    +                    'name': name,
    +                    'field': field,
    +                    'type': result.type
    +                });
    +            }
    +        }
    +
    +        if (this.#errorFields.length > 0) {
    +            $('#oe-field-errors').removeClass('hidden');
    +            $('#oe-field-errors').addClass('red pulse');
    +        } else {
    +            $('#oe-field-errors').addClass('hidden');
    +            $('#oe-field-errors').removeClass('red pulse');
    +        }
    +    }
    +
    +    isFieldOutsideViewport(field) {
    +        let result = false;
    +        let type = '';
    +        let stageWidth = this.#oeEditorStage.width();
    +        let stageHeight = this.#oeEditorStage.height();
    +
    +        let x = field.tlx;
    +        let y = field.tly;
    +
    +        /** Nasty hack to fix tlx and tly being wrong on scaled images */
    +        if (field instanceof OEIMAGEFIELD) {
    +            let tx = field.shape.getWidth()* field.scale/2;
    +            let ty = field.shape.getHeight()* field.scale/2;
    +            x = field.x - tx;
    +            y = field.y - ty;
    +        }
    +
    +        if (x < 0) {
    +            result = true;
    +            type = 'left';
    +        }
    +        if (y < 0) {
    +            result = true;
    +            type = 'top';
    +        }
    +
    +        if (x > stageWidth) {
    +            result = true;
    +            type = 'right';
    +        }
    +        if (y > stageHeight) {
    +            result = true;
    +            type = 'bottom';
    +        }
    +
    +        if ((x < 0 && y < 0) || (x > stageWidth && y > stageHeight)) {
    +            result = true;
    +            type = 'all';
    +        }
    +
    +        return {
    +            outOfBounds: result, 
    +            type: type
    +        };
    +    }
    +
    +    checkFieldBounds(shape, oeEditorStage, transformer=null) {
    +        if (transformer !== null) {
    +            if (transformer.borderStroke() !== '#00a1ff') {
    +                transformer.borderStroke('#00a1ff');
    +                transformer.borderStrokeWidth(1);
    +            }
             }
     
    -      
             let stageWidth = oeEditorStage.width();
             let stageHeight = oeEditorStage.height();
     
    @@ -1171,17 +1614,19 @@ class OEUIMANAGER {
                 outOfBounds = true;
             }
     
    -        if (outOfBounds) {
    +        if (outOfBounds && transformer !== null) {
                 transformer.borderStrokeWidth(3);
                 transformer.borderStroke('red');    
             }
     
    +        return outOfBounds;
         }
     
         #saveConfig() {
             this.#fieldManager.buildJSON();
             this.#configManager.saveConfig1();
             this.#fieldManager.clearDirty();
    +        this.#configManager.dirty = false;
             this.updateToolbar();
         }
     
    @@ -1261,28 +1706,56 @@ class OEUIMANAGER {
         }
     
         updateToolbar() {
    -        if (this.#selected === null) {
    +
    +        let selectedOverlay = this.#configManager.selectedOverlay;
    +        if (selectedOverlay.type === 'allsky' && !this.#debugMode)  {
                 $('#oe-delete').addClass('disabled');
    -            $('#oe-delete').removeClass('green');
    -        } else {
    +            $('#oe-save').addClass('disabled');
    +            $('#oe-add-text').addClass('disabled');
    +            $('#oe-add-image').addClass('disabled');
    +            $('#oe-item-list').addClass('disabled');
    +            $('#oe-test-mode').addClass('disabled');
    +            $('#oe-field-errors').addClass('disabled');
    +            $('#oe-toobar-debug-button').addClass('disabled');
    +            $('#oe-upload-font').addClass('disabled');
    +            $('#oe-show-image-manager').addClass('disabled');
    +            $('#oe-options').addClass('disabled');            
    +        } else {        
                 $('#oe-delete').removeClass('disabled');
    -            $('#oe-delete').addClass('green');
    -        }
    -
    -        if (this.#fieldManager.dirty) {
                 $('#oe-save').removeClass('disabled');
    -            $('#oe-save').addClass('green pulse');
    -            $('#oe-overlay-editor-tab').addClass('oe-overlay-editor-tab-modified');            
    -        } else {
    -            $('#oe-save').addClass('disabled');
    -            $('#oe-save').removeClass('green pulse');
    -            $('#oe-overlay-editor-tab').removeClass('oe-overlay-editor-tab-modified');
    -        }
    +            $('#oe-add-text').removeClass('disabled');
    +            $('#oe-add-image').removeClass('disabled');
    +            $('#oe-item-list').removeClass('disabled');
    +            $('#oe-test-mode').removeClass('disabled');
    +            $('#oe-field-errors').removeClass('disabled');
    +            $('#oe-toobar-debug-button').removeClass('disabled');
    +            $('#oe-upload-font').removeClass('disabled');
    +            $('#oe-show-image-manager').removeClass('disabled');
    +            $('#oe-options').removeClass('disabled');            
    +
    +            if (this.#selected === null) {
    +                $('#oe-delete').addClass('disabled');
    +                $('#oe-delete').removeClass('green');
    +            } else {
    +                $('#oe-delete').removeClass('disabled');
    +                $('#oe-delete').addClass('green');
    +            }
     
    -        if (this.#debugMode) {
    -            $('#oe-toolbar-debug').removeClass('hidden')
    -        } else {
    -            $('#oe-toolbar-debug').addClass('hidden')
    +            if (this.#fieldManager.dirty || this.#configManager.dirty) {
    +                $('#oe-save').removeClass('disabled');
    +                $('#oe-save').addClass('green pulse');
    +                $('#oe-overlay-editor-tab').addClass('oe-overlay-editor-tab-modified');            
    +            } else {
    +                $('#oe-save').addClass('disabled');
    +                $('#oe-save').removeClass('green pulse');
    +                $('#oe-overlay-editor-tab').removeClass('oe-overlay-editor-tab-modified');
    +            }
    +
    +            if (this.#debugMode) {
    +                $('#oe-toolbar-debug').removeClass('hidden')
    +            } else {
    +                $('#oe-toolbar-debug').addClass('hidden')
    +            }
             }
         }
     
    @@ -1331,7 +1804,8 @@ class OEUIMANAGER {
                 }
                 $('#fontuploadsubmit').removeClass('disabled');
             });
    -
    +        
    +        $('#fontuploadsubmit').off('click');
             $('#fontuploadsubmit').on('click', (e) => {
                 e.preventDefault();
                 $.ajax({
    diff --git a/html/js/settings.js b/html/js/settings.js
    new file mode 100644
    index 000000000..fe9735c93
    --- /dev/null
    +++ b/html/js/settings.js
    @@ -0,0 +1,165 @@
    +"use strict";
    +
    +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self,function(){var n=e.Cookies,o=e.Cookies=t();o.noConflict=function(){return e.Cookies=n,o}}())}(this,(function(){"use strict";function e(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var o in n)e[o]=n[o]}return e}var t=function t(n,o){function r(t,r,i){if("undefined"!=typeof document){"number"==typeof(i=e({},o,i)).expires&&(i.expires=new Date(Date.now()+864e5*i.expires)),i.expires&&(i.expires=i.expires.toUTCString()),t=encodeURIComponent(t).replace(/%(2[346B]|5E|60|7C)/g,decodeURIComponent).replace(/[()]/g,escape);var c="";for(var u in i)i[u]&&(c+="; "+u,!0!==i[u]&&(c+="="+i[u].split(";")[0]));return document.cookie=t+"="+n.write(r,t)+c}}return Object.create({set:r,get:function(e){if("undefined"!=typeof document&&(!arguments.length||e)){for(var t=document.cookie?document.cookie.split("; "):[],o={},r=0;r<t.length;r++){var i=t[r].split("="),c=i.slice(1).join("=");try{var u=decodeURIComponent(i[0]);if(o[u]=n.read(c,u),e===u)break}catch(e){}}return e?o[e]:o}},remove:function(t,n){r(t,"",e({},n,{expires:-1}))},withAttributes:function(n){return t(this.converter,e({},this.attributes,n))},withConverter:function(n){return t(e({},this.converter,n),this.attributes)}},{attributes:{value:Object.freeze(o)},converter:{value:Object.freeze(n)}})}({read:function(e){return'"'===e[0]&&(e=e.slice(1,-1)),e.replace(/(%[\dA-F]{2})+/gi,decodeURIComponent)},write:function(e){return encodeURIComponent(e).replace(/%(2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g,decodeURIComponent)}},{path:"/"});return t}));
    +
    +class ALLSKYSETTINGSCONTROLLER {
    +    #cookieName = 'allskysettingsstate';
    +    #showIcon = '<i class="fa fa-chevron-down fa-fw"></i>';
    +	#hideIcon = '<i class="fa fa-chevron-up fa-fw"></i>';
    +
    +    constructor() {
    +        this.#readState();
    +    }
    +    
    +    #saveState() {
    +        return;
    +        var states = {};
    +
    +        $('.setting-header-toggle').each((index, el) => {
    +            let sectionNumber = $(el).data('settinggroup');
    +            let section = $('#header' + sectionNumber);
    +            let state = section.css('display');
    +
    +            states[sectionNumber]= state;
    +        });
    +
    +        var jsonString = JSON.stringify(states);
    +        Cookies.set(this.#cookieName, jsonString);
    +    }
    +
    +    #readState() {
    +        return;
    +        let cookieData = Cookies.get(this.#cookieName);
    +
    +        try {
    +            let states = JSON.parse(cookieData);
    +            for (let key in states) {
    +                if (states[key] !== 'none') {
    +                    this.#openSettingsGroup(key);
    +                } else {
    +                    this.#closeSettingsGroup(key);
    +                }
    +            }
    +
    +            this.#setAllButtonState();
    +        } catch(err) {
    +            // Ignore any errors
    +        }
    +    }
    +
    +    #setAllButtonState() {
    +        var allOpen = true;
    +        $('.settings-header').each((index, element) => {
    +            let el = $(element);
    +            let state = el.css('display');
    +
    +            if (state === 'none') {
    +                allOpen = false;
    +            }
    +        });
    +
    +        if (!allOpen) {
    +            $('#settings-all-control').html(this.#showIcon);
    +        } else {
    +            $('#settings-all-control').html(this.#hideIcon);
    +        }        
    +    }
    +
    +    #openSettingsGroup(sectionNumber) {
    +        let headerEl = $('#h' + sectionNumber);
    +        let sectionEl = $('#header' + sectionNumber);
    +
    +        $(headerEl).html(this.#hideIcon);
    +        $(sectionEl).css('display', 'table-row');
    +    }
    +
    +    #closeSettingsGroup(sectionNumber) {
    +        let headerEl = $('#h' + sectionNumber);
    +        let sectionEl = $('#header' + sectionNumber);
    +
    +        $(headerEl).html(this.#showIcon);
    +        $(sectionEl).css('display', 'none');
    +    }
    +
    +    run() {
    +        /**
    +         * Handle clicking open/close all settings button
    +         */
    +        $('#settings-all-control').on('click', (event) => {
    +            let jEl = $(event.currentTarget);
    +
    +            $('.setting-header-toggle').each((index, element) => {
    +                let el = $(element);
    +                let sectionNumber = el.data('settinggroup');
    +                
    +                if (jEl.hasClass('settings-expand')) {
    +                    this.#openSettingsGroup(sectionNumber)
    +                } else {
    +                    this.#closeSettingsGroup(sectionNumber);
    +                }             
    +            });
    +            
    +            this.#setAllButtonState();
    +
    +            if (jEl.hasClass('settings-expand')) {
    +                jEl.removeClass('settings-expand')
    +            } else {
    +                jEl.addClass('settings-expand')
    +            }
    +
    +            this.#saveState();
    +        });
    +
    +        /**
    +         * Handle clicking open/close a settings group
    +         */
    +        $('.setting-header-toggle').on('click', (event) => {
    +            let el = $(event.currentTarget);
    +            let sectionNumber = el.data('settinggroup');
    +            let section = $('#header' + sectionNumber);
    +            let state = section.css('display');
    +
    +            if (state === 'none') {
    +                this.#openSettingsGroup(sectionNumber);
    +            } else {
    +                this.#closeSettingsGroup(sectionNumber);
    +            }
    +            this.#setAllButtonState();
    +            this.#saveState();            
    +        });
    +
    +        /**
    +         * Handle scroll back to top of the page
    +         */
    +        $('#backToTopBtn').on('click', (event) => {
    +            event.preventDefault();
    +            $('html,body').animate({
    +                scrollTop: 0
    +              }, 1000);
    +        });        
    +        
    +        $(document).on( 'scroll', (event) => {
    +            let top = $(event.currentTarget).scrollTop();
    +            if (top > 20) {
    +                $('#backToTopBtn').show();
    +            } else {
    +                $('#backToTopBtn').hide();
    +            }
    +        });
    +
    +        $('#settings-reset').on('click', (event) => {
    +
    +            let result = confirm('Really RESET ALL VALUES TO DEFAULT??');
    +
    +            if (result === false) {
    +                event.preventDefault();
    +            }
    +
    +        });
    +        
    +
    +    }
    +  }
    +
    +  let settingController = new ALLSKYSETTINGSCONTROLLER();
    +  settingController.run();
    diff --git a/html/public.php b/html/public.php
    index 860cd63f5..79ddd0132 100644
    --- a/html/public.php
    +++ b/html/public.php
    @@ -16,13 +16,14 @@
     	<meta name="viewport" content="width=device-width, initial-scale=1">
     	<meta name="description" content="">
     	<meta name="author" content="Thomas Jacquin">
    +	<link href="documentation/css/custom.css" rel="stylesheet">
     	<title>AllSky Public Page</title>
     </head>
     <body>
     
     <div class="row">
    -	<div id="live_container" style="background-color: black;">
    -		<img id="current" class="current" src="<?php echo $image_name ?>" style="width:100%">
    +	<div id="live_container" class="live_container">
    +		<img id="current" class="current" src="<?php echo $image_name ?>">
     	</div>
     </div>
     
    @@ -33,21 +34,14 @@ function getImage() {
     		var newImg = new Image();
     		newImg.src = '<?php echo $image_name ?>?_ts=' + new Date().getTime();
     		newImg.id = "current";
    -		newImg.class = "current";
    -		newImg.style = "width: 100%";
    -
    +		newImg.className = "current";
     		newImg.decode().then(() => {
    -			$("#current").attr('src', newImg.src)
    -				.attr("id", "current")
    -				.attr("class", "current")
    -				.css("width", "100%")
    -				.on('load', function () {
    -					if (!this.complete || typeof this.naturalWidth == "undefined" || this.naturalWidth == 0) {
    -						console.log('broken image!');
    -					} else {
    -						$("#live_container").empty().append(newImg);
    -					}
    -				});
    +				$("#live_container").empty().append(newImg);
    +			}).catch((err) => {
    +				if (!this.complete || typeof this.naturalWidth == "undefined" || this.naturalWidth == 0) {
    +					console.log('broken image: ', err);
    +				}
    +			});
     		}).finally(() => {
     			// Use tail recursion to trigger the next invocation after `$delay` milliseconds
     			setTimeout(function () { getImage(); }, <?php echo $delay ?>);
    diff --git a/install.sh b/install.sh
    index 7b270721e..87ae62db1 100755
    --- a/install.sh
    +++ b/install.sh
    @@ -8,65 +8,72 @@ ME="$( basename "${BASH_ARGV0}" )"
     source "${ALLSKY_HOME}/variables.sh"					|| exit "${EXIT_ERROR_STOP}"
     #shellcheck source-path=scripts
     source "${ALLSKY_SCRIPTS}/functions.sh"					|| exit "${EXIT_ERROR_STOP}"
    -
    -# This file defines functions plus sets many variables.
     #shellcheck source-path=scripts
     source "${ALLSKY_SCRIPTS}/installUpgradeFunctions.sh"	|| exit "${EXIT_ERROR_STOP}"
     
    -if [[ ${EUID} -eq 0 ]]; then
    -	display_msg error "This script must NOT be run as root, do NOT use 'sudo'."
    -	exit 1
    -fi
    -
    -# This script assumes the user already did the "git clone" into ${ALLSKY_HOME}.
    -
    -# Some versions of Linux default to 750 so web server can't read it
    -chmod 755 "${ALLSKY_HOME}"  							|| exit "${EXIT_ERROR_STOP}"
    +# Default may be 700 (HOME) or 750 (ALLSKY_HOME) so web server can't read it
    +chmod 755 "${HOME}" "${ALLSKY_HOME}"					|| exit "${EXIT_ERROR_STOP}"
     cd "${ALLSKY_HOME}"  									|| exit "${EXIT_ERROR_STOP}"
     
    -# PRIOR_ALL_DIR is passed to us and is the location of an optional prior copy of Allsky.
    -PRIOR_CONFIG_DIR="${PRIOR_ALLSKY_DIR}/config"
    -PRIOR_CONFIG_FILE="${PRIOR_CONFIG_DIR}/config.sh"
    -PRIOR_FTP_FILE="${PRIOR_CONFIG_DIR}/ftp-settings.sh"	# may change depending on old version
    +# The POST_INSTALLATION_ACTIONS contains information the user needs to act upon after the reboot.
    +rm -f "${POST_INSTALLATION_ACTIONS}"		# Shouldn't be there, but just in case.
    +rm -f "${ALLSKY_MESSAGES}"					# Start out with no messages.
    +
     
    -TITLE="Allsky Installer - ${ALLSKY_VERSION}"
    +SHORT_TITLE="Allsky Installer"
    +TITLE="${SHORT_TITLE} - ${ALLSKY_VERSION}"
     FINAL_SUDOERS_FILE="/etc/sudoers.d/allsky"
     OLD_RASPAP_DIR="/etc/raspap"			# used to contain WebUI configuration files
    -SETTINGS_FILE_NAME="$(basename "${SETTINGS_FILE}")"
    +SETTINGS_FILE_NAME="$( basename "${SETTINGS_FILE}" )"
     FORCE_CREATING_DEFAULT_SETTINGS_FILE="false"	# should a default settings file be created?
     RESTORED_PRIOR_SETTINGS_FILE="false"
     PRIOR_SETTINGS_FILE=""					# Full pathname to the prior settings file, if it exists
    -RESTORED_PRIOR_CONFIG_SH="false"		# prior config.sh restored?
    -RESTORED_PRIOR_FTP_SH="false"			# prior ftp-settings.sh restored?
    -PRIOR_ALLSKY=""							# Set to "new" or "old" if they have a prior version
    -PRIOR_ALLSKY_VERSION=""					# The version number of the prior version, if known
    +COPIED_PRIOR_CONFIG_SH="false"			# prior config.sh's settings copied to settings file?
    +COPIED_PRIOR_FTP_SH="false"				# prior ftp-settings.sh's settings copied to settings file?
     SUGGESTED_NEW_HOST_NAME="allsky"		# Suggested new host name
     NEW_HOST_NAME=""						# User-specified host name
     BRANCH="${GITHUB_MAIN_BRANCH}"			# default branch
    -
    -# Repo files
    -REPO_SUDOERS_FILE="${ALLSKY_REPO}/sudoers.repo"
    -REPO_WEBUI_DEFINES_FILE="${ALLSKY_REPO}/allskyDefines.inc.repo"
    -REPO_LIGHTTPD_FILE="${ALLSKY_REPO}/lighttpd.conf.repo"
    -REPO_AVI_FILE="${ALLSKY_REPO}/avahi-daemon.conf.repo"
    -
    -# The POST_INSTALLATION_ACTIONS contains information the user needs to act upon after the reboot.
    -rm -f "${POST_INSTALLATION_ACTIONS}"		# Shouldn't be there, but just in case.
    -
    -rm -f "${ALLSKY_MESSAGES}"					# Start out with no messages.
    -
    -# display_msg() will send "log" entries to this file.
    -# DISPLAY_MSG_LOG is used in display_msg()
     # shellcheck disable=SC2034
    -DISPLAY_MSG_LOG="${ALLSKY_INSTALLATION_LOGS}/install.sh.log"
    -
    -# Is a reboot needed at end of installation?
    -REBOOT_NEEDED="true"
    -# Does Allsky need to be configured at end of installation?
    -CONFIGURATION_NEEDED="true"
    +DISPLAY_MSG_LOG="${ALLSKY_LOGS}/install.log"		# display_msg() sends log entries to this file.
    +LONG_BITS=$( getconf LONG_BIT ) # Size of a long, 32 or 64
    +REBOOT_NEEDED="true"					# Is a reboot needed at end of installation?
    +CONFIGURATION_NEEDED="true"				# Does Allsky need to be configured at end of installation?
    +SPACE="    "
    +NOT_RESTORED="NO PRIOR VERSION"
    +TMP_FILE="/tmp/x"						# temporary file used by many functions
    +TAB="$( echo -e '\t' )"
    +
    +# Overlay variables
    +SENSOR_WIDTH=""
    +SENSOR_HEIGHT=""
    +FULL_OVERLAY_NAME=""
    +SHORT_OVERLAY_NAME=""
    +OVERLAY_NAME=""
    +
    +##### Allsky versions.   ${ALLSKY_VERSION} is set in variables.sh
    +#xxx currently not used:    ALLSKY_BASE_VERSION="$( remove_point_release "${ALLSKY_VERSION}" )"
    +	# Base of first version with combined configuration files and all lowercase setting names.
    +COMBINED_BASE_VERSION="v2024.12.06"
    +	# Base of first version with CAMERA_TYPE instead of CAMERA in config.sh and
    +	# "cameratype" in the settings file.
    +FIRST_CAMERA_TYPE_BASE_VERSION="v2023.05.01"
    +	# First Allsky version that used the "version" file.
    +	# It's also when ftp-settings.sh moved to ${ALLSKY_CONFIG}
    +FIRST_VERSION_VERSION="v2022.03.01"
    +	# Versions before ${FIRST_VERSION_VERSION} that didn't have version numbers.
    +PRE_FIRST_VERSION_VERSION="old"
    +
    +##### Information on the prior Allsky version, if used
    +USE_PRIOR_ALLSKY="false"
    +PRIOR_ALLSKY_STYLE=""			# Set to the style if they have a prior version
    +PRIOR_ALLSKY_VERSION=""			# The version number of the prior version, if known
    +PRIOR_ALLSKY_BASE_VERSION=""	# The base version number of the prior version, if known
    +PRIOR_CAMERA_TYPE=""
    +PRIOR_CAMERA_MODEL=""
    +PRIOR_CAMERA_NUMBER=""
     
     # Holds status of installation if we need to exit and get back in.
    -STATUS_FILE="${ALLSKY_INSTALLATION_LOGS}/status.txt"
    +STATUS_FILE="${ALLSKY_LOGS}/install_status.txt"
     STATUS_FILE_TEMP="${ALLSKY_TMP}/temp_status.txt"	# holds intermediate status
     STATUS_LOCALE_REBOOT="Rebooting to change locale"	# status of rebooting due to locale change
     STATUS_FINISH_REBOOT="Rebooting to finish installation"
    @@ -74,6 +81,7 @@ STATUS_NO_FINISH_REBOOT="Did not reboot to finish installation"
     STATUS_NO_REBOOT="User elected not to reboot"
     STATUS_NO_LOCALE="Desired locale not found"			# exiting due to desired locale not installed
     STATUS_NO_CAMERA="No camera found"					# status of exiting due to no camera found
    +STATUS_NO_LAT_LONG="Latitude and/or Longitude not entered"
     STATUS_OK="OK"										# Installation was completed.
     STATUS_NOT_CONTINUE="User elected not to continue"	# Exiting, but not an error
     STATUS_CLEAR="Clear"								# Clear the file
    @@ -81,107 +89,116 @@ STATUS_ERROR="Error encountered"
     STATUS_INT="Got interrupt"
     STATUS_VARIABLES=()									# Holds all the variables and values to save
     
    -LONG_BITS=$( getconf LONG_BIT ) # Size of a long, 32 or 64
    -
    -# Check if any extra modules are installed
    -if [[ -n "$( find /opt/allsky/modules -type f -name "*.py" -print -quit 2> /dev/null )" ]]; then
    -	EXTRA_MODULES_INSTALLED="true"
    -else
    -	EXTRA_MODULES_INSTALLED="false"
    -fi
    +##### Set in installUpgradeFunctions.sh
    +# PRIOR_ALLSKY_DIR
    +# PRIOR_CONFIG_DIR
    +# PRIOR_REMOTE_WEBSITE_CONFIGURATION_FILE
    +# PRIOR_CONFIG_FILE, PRIOR_FTP_FILE
    +# PRIOR_PYTHON_VENV
    +# WEBSITE_CONFIG_VERSION, WEBSITE_ALLSKY_VERSION
    +# ALLSKY_DEFINES_INC, REPO_WEBUI_DEFINES_FILE
    +# REPO_SUDOERS_FILE, REPO_LIGHTTPD_FILE, REPO_AVI_FILE, REPO_OPTIONS_FILE
    +# LIGHTTPD_LOG_DIR, LIGHTTPD_LOG_FILE
    +# Plus others I probably forgot about...
     
    -# TODO: check the CURRENT Allsky, or the PRIOR one?
    -
    -# Check if we have a venv already. If not then the install/update will create it
    -# but we need to warn the user to reinstall the extra modules if they have them.
    -if [[ -d "${ALLSKY_PYTHON_VENV}" ]]; then
    -	INSTALLED_VENV="false"
    -else
    -	INSTALLED_VENV="true"
    -fi
     
     ############################################## functions
     
     ####
    -# 
    +#
     do_initial_heading()
     {
    +	[[ ${SKIP} == "true" ]] && return
     	if [[ ${UPDATE} == "true" ]]; then
     		display_header "Updating Allsky"
     		return
     	fi
     
    -	if [[ ${do_initial_heading} == "true" ]]; then
    -		display_header "Welcome back to the ${TITLE}!"
    +	local MSG  X  H
    +
    +	declare -n v="${FUNCNAME[0]}"
    +	if [[ ${v} == "true" ]]; then
    +		display_header "Welcome back to the ${SHORT_TITLE}!"
     	else
    -		MSG="Welcome to the ${TITLE}!\n"
    +		MSG="Welcome to the ${SHORT_TITLE}!\n"
    +
    +		if [[ ${RESTORE} == "true" ]]; then
    +			H="$( basename "${ALLSKY_HOME}" )"
    +			X="$( basename "${RENAMED_DIR}" )"
    +			MSG+="\nYour current '${H}' directory will be renamed to"
    +			MSG+="\n    ${X}"
    +			X="$( basename "${PRIOR_ALLSKY_DIR}" )"
    +			MSG+="\nand the prior Allsky in '${X}' will be"
    +			MSG+=" renamed to back to '${H}'."
    +			MSG+="\n\nFiles that were moved from the old release to the current one"
    +			MSG+=" will be moved back."
    +			MSG+="\nYou will manually need to restart Allsky after checking that"
    +			MSG+=" the settings are correct in the WebUI."
    +
    +		elif [[ ${USE_PRIOR_ALLSKY} == "true" ]]; then
    +			MSG+="\nYou will be asked if you want to use the images and darks"
    +			MSG+=" from your prior version of Allsky."
     
    -		if [[ -n ${PRIOR_ALLSKY} ]]; then
    -			MSG="${MSG}\nYou will be asked if you want to use the images and darks (if any) from"
    -			MSG="${MSG} your prior version of Allsky."
    -			if [[ ${PRIOR_ALLSKY} == "newStyle" ]]; then
    -				MSG="${MSG}\nIf so, its settings will be used as well."
    -			else
    -				MSG="${MSG}\nIf so, we will attempt to use its settings as well, but may not be"
    -				MSG="${MSG}\nable to use ALL prior settings depending on how old your prior Allsky is."
    -				MSG="${MSG}\nIn that case, you'll be prompted for required information such as"
    -				MSG="${MSG}\nthe camera's latitude, logitude, and locale."
    -			fi
     		else
    -			MSG="${MSG}\nYou will be prompted for required information such as the type"
    -			MSG="${MSG}\nof camera you have and the camera's latitude, logitude, and locale."
    +			MSG+="\nYou will be prompted for required information such as the type"
    +			MSG+="\nof camera you have and the camera's latitude, logitude, and locale."
     		fi
     
    -		MSG="${MSG}\n\nNOTE: your camera must be connected to the Pi before continuing."
    -		MSG="${MSG}\n\nContinue?"
    +		if [[ ${RESTORE} != "true" ]]; then
    +			MSG+="\n\nNOTE: your camera must be connected to the Pi before continuing."
    +		fi
    +		MSG+="\n\nContinue?"
     		if ! whiptail --title "${TITLE}" --yesno "${MSG}" 25 "${WT_WIDTH}"  3>&1 1>&2 2>&3; then
     			display_msg "${LOG_TYPE}" info "User not ready to continue."
     			exit_installation 1 "${STATUS_CLEAR}" ""
     		fi
     
    -		display_header "Welcome to the ${TITLE}"
    +		display_header "Welcome to the ${SHORT_TITLE}"
     	fi
     
    -	[[ ${do_initial_heading} != "true" ]] && STATUS_VARIABLES+=("do_initial_heading='true'\n")
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} != "true" ]] && STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
     }
     
     ####
     usage_and_exit()
     {
    +	local RET C MSG
    +
     	RET=${1}
     	if [[ ${RET} -eq 0 ]]; then
     		C="${YELLOW}"
     	else
     		C="${RED}"
     	fi
    -	# Don't show --testing option since users shouldn't use it.
    -	echo
    -	echo -e "${C}Usage: ${ME} [--help] [--debug [...]] [--update] [--function function]${NC}"
    -	echo
    -	echo "'--help' displays this message and exits."
    -	echo
    -	echo "'--debug' displays debugging information. Can be called multiple times to increase level."
    -	echo
    -	echo "'--update' should only be used when instructed to by the Allsky Website."
    -	echo
    -	echo "'--function' executes the specified function and quits."
    -	echo
    +	MSG="Usage: ${ME} [--help] [--debug [...]] [--fix |--update | --restore | --function function]"
    +	{
    +		echo -e "\n${C}${MSG}${NC}"
    +		echo
    +		echo "'--help' displays this message and exits."
    +		echo
    +		echo "'--debug' displays debugging information. Can be called multiple times to increase level."
    +		echo
    +		echo "'--fix' should only be used when instructed to by the Allsky Website."
    +		echo
    +		echo "'--update' should only be used when instructed to by the Allsky Website."
    +		echo
    +		echo "'--restore' restores ${PRIOR_ALLSKY_DIR} to ${ALLSKY_HOME}."
    +		echo
    +		echo "'--function' executes the specified function and quits."
    +		echo
    +	} >&2
     	exit_installation "${RET}"
     }
     
     
    -####
    -# Stop Allsky.  If it's not running, nothing happens.
    -stop_allsky()
    -{
    -	sudo systemctl stop allsky 2> /dev/null
    -}
    -
    -
     ####
     # Get the branch of the release we are installing;
     get_this_branch()
     {
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
    +	local B		# BRANCH is global
    +
    +	#shellcheck disable=SC2119
     	if ! B="$( get_branch )" ; then
     		display_msg --log warning "Unable to determine branch; assuming '${BRANCH}'."
     	else
    @@ -189,8 +206,8 @@ get_this_branch()
     		display_msg --logonly info "Using the '${BRANCH}' branch."
     	fi
     
    -	STATUS_VARIABLES+=("get_this_branch='true'\n")
     	STATUS_VARIABLES+=("BRANCH='${BRANCH}'\n")
    +	STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
     }
     
     
    @@ -200,6 +217,7 @@ do_function()
     {
     	local FUNCTION="${1}"
     	shift
    +
     	if ! type "${FUNCTION}" > /dev/null; then
     		display_msg error "Unknown function: '${FUNCTION}'."
     		exit 1
    @@ -220,8 +238,13 @@ CAMERA_TYPE_to_CAMERA()
     	elif [[ ${CAMERA_TYPE} == "RPi" ]]; then
     		echo "RPiHQ"		# RPi cameras used to be called "RPiHQ".
     	else
    -		display_msg --log error "Unknown CAMERA_TYPE: '${CAMERA_TYPE}'"
    -		exit_installation 1 "${STATUS_ERROR}" "unknown CAMERA_TYPE: '${CAMERA_TYPE}'"
    +		if [[ -n ${CAMERA_TYPE} ]]; then
    +			MSG="Unknown CAMERA_TYPE: '${CAMERA_TYPE}'"
    +		else
    +			MSG="'CAMERA_TYPE' not defined."
    +		fi
    +		display_msg --log error "${MSG}"
    +		exit_installation 1 "${STATUS_ERROR}" "${MSG}"
     	fi
     }
     ####
    @@ -234,55 +257,128 @@ CAMERA_to_CAMERA_TYPE()
     	elif [[ ${CAMERA} == "RPiHQ" ]]; then
     		echo "RPi"
     	else
    +		if [[ -n ${CAMERA} ]]; then
    +			MSG="Unknown CAMERA: '${CAMERA}'"
    +		else
    +			MSG="'CAMERA' not defined."
    +		fi
     		display_msg --log error "Unknown CAMERA: '${CAMERA}'"
    -		exit_installation 1 "${STATUS_CLEAR}" "unknown CAMERA: '${CAMERA}'"
    +		exit_installation 1 "${STATUS_CLEAR}" "${MSG}"
    +	fi
    +}
    +
    +
    +#######
    +# Set up the file that contains information on all supported RPi cameras.
    +# Have separate function so it can be called from "--function".
    +setup_rpi_supported_cameras()
    +{
    +	local CMD="${1}"
    +	local notCMD
    +
    +	if [[ ! -f ${RPi_SUPPORTED_CAMERAS} ]]; then
    +		local B="$( basename "${RPi_SUPPORTED_CAMERAS}" )"
    +		if [[ -z ${CMD} ]]; then
    +			notCMD="xxxxx"		# won't match anything
    +			CMD="all"
    +		elif [[ ${CMD} == "raspistill" ]]; then
    +			notCMD="libcamera"
    +		else
    +			notCMD="raspistill"
    +		fi
    +
    +		local MSG="Creating ${RPi_SUPPORTED_CAMERAS} with '${CMD}' entries."
    +		display_msg --log progress "${MSG}"
    +
    +		# Remove comment and blank lines and lines for the command we are NOT using.
    +		grep -v -E "^\$|^#|^${notCMD}" "${ALLSKY_REPO}/${B}.repo" > "${RPi_SUPPORTED_CAMERAS}"
     	fi
     }
     
     #######
    -CONNECTED_CAMERAS=""
    +CONNECTED_CAMERA_MODELS=""
    +NUM_CONNECTED_CAMERAS=0
    +CT=()			# Camera Type array - what to display in whiptail
    +
     get_connected_cameras()
     {
    -	local CC
    -	# If we can't determine the camera to use for RPi cameras it either means there is
    -	# no RPi camera, or something's wrong.
    -	if determineCommandToUse "false" "" > /dev/null 2>&1 ; then
    -		display_msg --log progress "RPi camera found."
    -		CC="RPi"
    +	local CMD  CC  MSG   NUM_RPI=0   NUM_ZWO=0
    +
    +	# true == ignore errors.  ${CMD} will be "" if no command found.
    +	CMD="$( determineCommandToUse "false" "" "true" 2> /dev/null )"
    +	setup_rpi_supported_cameras "${CMD}"		# Will create full file is CMD == ""
    +
    +	# RPi format:	RPi \t camera_number \t camera_sensor [\t optional_other_stuff]
    +	# ZWO format:	ZWO \t camera_number \t camera_model
    +	# "true" == ignore errors
    +	get_connected_cameras_info "true" > "${CONNECTED_CAMERAS_INFO}" 2>/dev/null
    +
    +	# Get the RPi connected cameras, if any.
    +	CC=""
    +	if [[ -n ${CMD} ]]; then
    +		local RPI_MODELS="$( get_connected_camera_models --full "RPi" )"
    +		# Output from above is:
    +		#	RPi \t camera_number \t camera_model \t camera_sensor
    +		if [[ -n ${RPI_MODELS} ]]; then
    +			CC="RPi"
    +			local CT_ CN_ MODEL SENSOR
    +   			# shellcheck disable=SC2034
    +			while read -r CT_ CN_ MODEL SENSOR
    +			do
    +				MODEL="${MODEL//++/ }"
    +				SENSOR="${SENSOR//++/ }"
    +				local FULL_NAME="${MODEL}  (${SENSOR})"
    +				[[ -z ${FUNCTION} ]] && display_msg --log progress "RPi ${FULL_NAME} camera found."
    +				CT+=("${NUM_RPI};RPi;${MODEL}" "RPi     ${FULL_NAME}")
    +				((NUM_RPI++))
    +			done <<<"${RPI_MODELS// /++}"		# replace any spaces
    +		fi
     	fi
    -	if lsusb -d "03c3:" > /dev/null ; then
    -		display_msg --log progress "ZWO camera found."
    -		[[ -n ${CC} ]] && CC="${CC} "
    -		CC="${CC}ZWO"
    +
    +	# Get the ZWO connected cameras, if any.
    +	local ZWO_MODELS="$( get_connected_camera_models "ZWO" )"
    +	if [[ -n ${ZWO_MODELS} ]]; then
    +		[[ -n ${CC} ]] && CC+=" "
    +		CC+="ZWO"
    +		for X in ${ZWO_MODELS// /++}
    +		do
    +			MODEL="${X//++/ }"
    +			[[ -z ${FUNCTION} ]] && display_msg --log progress "ZWO ${MODEL} camera found."
    +			CT+=( "${NUM_ZWO};ZWO;${MODEL}" "ZWO     ${MODEL}" )
    +			((NUM_ZWO++))
    +		done
     	fi
     
    -	if [[ -z ${CC} ]]; then
    +	NUM_CONNECTED_CAMERAS=$(( NUM_RPI + NUM_ZWO ))
    +	if [[ ${NUM_CONNECTED_CAMERAS} -eq 0 ]]; then
     		MSG="No connected cameras were detected.  The installation will exit."
    +		MSG+="\nMake sure a camera is plugged in and working prior to restarting"
    +		MSG+=" the installation."
     		whiptail --title "${TITLE}" --msgbox "${MSG}" 12 "${WT_WIDTH}" 3>&1 1>&2 2>&3
     
     		MSG="No connected cameras were detected."
    -		MSG="${MSG}\nMake sure a camera is plugged in and working prior to restarting"
    -		MSG="${MSG} the installation."
     		display_msg --log error "${MSG}"
     		exit_installation 1 "${STATUS_NO_CAMERA}" ""
     	fi
     
    -	if [[ -n ${CONNECTED_CAMERAS} ]]; then
    -		# Set from a prior installation.
    -		if [[ ${CONNECTED_CAMERAS} != "${CC}" ]]; then
    -			MSG="Connected cameras were '${CONNECTED_CAMERAS}' during last installation"
    -			MSG="${MSG} but are '${CC}' now."
    +	declare -n v="${FUNCNAME[0]}";
    +	[[ ${v} != "true" ]] && STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
    +
    +	# CONNECTED_CAMERAS_MODELS was set from a prior installation, if any.
    +	# If it was set, warn the user if the prior models is different than
    +	# the current ones, but it's not an error.
    +	if [[ -n ${CONNECTED_CAMERA_MODELS} ]]; then
    +		if [[ ${CONNECTED_CAMERA_MODELS} != "${CC}" ]]; then
    +			MSG="Connected cameras were '${CONNECTED_CAMERA_MODELS}' during last installation"
    +			MSG+=" but are '${CC}' now."
     			display_msg --log info "${MSG}"
    -			STATUS_VARIABLES+=("CONNECTED_CAMERAS='${CC}'\n")
    +			STATUS_VARIABLES+=("CONNECTED_CAMERA_MODELS='${CC}'\n")
    +			CONNECTED_CAMERA_MODELS="${CC}"
     		fi
    -		# Else the last one and this one are the same so don't save.
    -		CONNECTED_CAMERAS="${CC}"
     		return
     	fi
     
    -	[[ ${get_connected_cameras} != "true" ]] && STATUS_VARIABLES+=("get_connected_cameras='true'\n")
    -	# Either not set before or is different this time
    -	CONNECTED_CAMERAS="${CC}"
    +	CONNECTED_CAMERA_MODELS="${CC}"	# Either not set before or is different this time
     }
     
     #
    @@ -292,155 +388,100 @@ get_connected_cameras()
     CAMERA_TYPE=""
     select_camera_type()
     {
    -	if [[ -n ${PRIOR_ALLSKY} ]]; then
    -		case "${PRIOR_ALLSKY_VERSION}" in
    -			# New versions go here...
    -			v2023.05.01*)
    -				# New style Allsky using ${CAMERA_TYPE}.
    -				CAMERA_TYPE="${PRIOR_CAMERA_TYPE}"
    +	local MSG  CAMERA  NEW  S  CAMERA_INFO
    +	# CAMERA_TYPE and NUM_CONNECTED_CAMERAS are global
     
    -				# Don't bother with a message since this is a "similar" release.
    -				if [[ -n ${CAMERA_TYPE} ]]; then
    -					MSG="Using Camera Type '${CAMERA_TYPE}' from prior Allsky."
    -					STATUS_VARIABLES+=("select_camera_type='true'\n")
    -					STATUS_VARIABLES+=("CAMERA_TYPE='${CAMERA_TYPE}'\n")
    -					display_msg --logonly info "${MSG}"
    -					return
    -				else
    -					MSG="Camera Type not in prior new-style settings file."
    -					display_msg --log error "${MSG}"
    +	if [[ ${USE_PRIOR_ALLSKY} == "true" ]]; then
    +		# bash doesn't have ">=" so we have to use "! ... < "
    +		if [[ ! ${PRIOR_ALLSKY_VERSION} < "${FIRST_CAMERA_TYPE_BASE_VERSION}" ]]; then
    +			# New style Allsky using ${CAMERA_TYPE}.
    +			CAMERA_TYPE="${PRIOR_CAMERA_TYPE}"
    +
    +			if [[ -n ${CAMERA_TYPE} ]]; then
    +				MSG="Using Camera Type '${CAMERA_TYPE}' from prior Allsky; not prompting user."
    +				display_msg --logonly info "${MSG}"
    +				STATUS_VARIABLES+=("CAMERA_TYPE='${CAMERA_TYPE}'\n")
    +				if [[ -n ${CAMERA_MODEL} ]]; then
    +					STATUS_VARIABLES+=("CAMERA_MODEL='${CAMERA_MODEL}'\n")
     				fi
    -				;;
    -
    -			"v2022.03.01" | "old")
    -				local CAMERA="$( get_variable "CAMERA" "${PRIOR_CONFIG_FILE}" )"
    -				if [[ -n ${CAMERA} ]]; then
    -					CAMERA_TYPE="$( CAMERA_to_CAMERA_TYPE "${CAMERA}" )"
    -					STATUS_VARIABLES+=("select_camera_type='true'\n")
    -					STATUS_VARIABLES+=("CAMERA_TYPE='${CAMERA_TYPE}'\n")
    -					if [[ ${CAMERA} != "${CAMERA_TYPE}" ]]; then
    -						NEW=" (now called ${CAMERA_TYPE})"
    -					else
    -						NEW=""
    -					fi
    -					display_msg --log progress "Using prior ${CAMERA} camera${NEW}."
    -					return
    -				else
    -					MSG="CAMERA not in prior old-style config.sh."
    -					display_msg --log warning "${MSG}"
    +				if [[ -n ${CAMERA_NUMBER} ]]; then
    +					STATUS_VARIABLES+=("CAMERA_NUMBER='${CAMERA_NUMBER}'\n")
     				fi
    -				;;
    -		esac
    -	fi
    -
    -	local CT=()
    -	local NUM=0
    -	if [[ ${CONNECTED_CAMERAS} == "RPi" ]]; then
    -		CT+=("RPi" "     Raspberry Pi (HQ, Module 3, and compatibles)")
    -		((NUM++))
    -	elif [[ ${CONNECTED_CAMERAS} == "ZWO" ]]; then
    -		CT+=("ZWO" "     ZWO ASI")
    -		((NUM++))
    -	elif [[ ${CONNECTED_CAMERAS} == "RPi ZWO" ]]; then
    -		CT+=("RPi" "     Raspberry Pi (HQ, Module 3, and compatibles)")
    -		CT+=("ZWO" "     ZWO ASI")
    -		((NUM+=2))
    -	else		# shouldn't happen since we already checked
    -		MSG="INTERNAL ERROR:"
    -		if [[ -z ${CONNECTED_CAMERAS} ]]; then
    -			MSG="${MSG} CONNECTED_CAMERAS is empty."
    +				STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
    +				return
    +			else
    +				MSG="Camera Type not in prior new-style settings file."
    +				display_msg --log error "${MSG}"
    +				exit_installation 2 "${STATUS_NO_CAMERA}" "${MSG}"
    +			fi
     		else
    -			MSG="${MSG} CONNECTED_CAMERAS (${CONNECTED_CAMERAS}) is invalid."
    +			# Older style using ${CAMERA}
    +			CAMERA="$( get_variable "CAMERA" "${PRIOR_CONFIG_FILE}" )"
    +			if [[ -n ${CAMERA} ]]; then
    +				CAMERA_TYPE="$( CAMERA_to_CAMERA_TYPE "${CAMERA}" )"
    +				if [[ ${CAMERA} != "${CAMERA_TYPE}" ]]; then
    +					NEW=" (now called ${CAMERA_TYPE})"
    +				else
    +					NEW=""
    +				fi
    +				display_msg --log progress "Using prior ${CAMERA} camera${NEW}."
    +				STATUS_VARIABLES+=("CAMERA_TYPE='${CAMERA_TYPE}'\n")
    +				# Old style doesn't have CAMERA_MODEL or CAMERA_NUMBER.
    +				STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
    +				return
    +			else
    +				MSG="CAMERA not in old-style '${PRIOR_CONFIG_FILE}'.sh."
    +				display_msg --log warning "${MSG}"
    +			fi
     		fi
    -		display_msg --log error "${MSG}"
    -		exit_installation 2 "${STATUS_NO_CAMERA}" "${MSG}"
     	fi
     
    -	local S=" is"
    -	[[ ${NUM} -gt 1 ]] && S="s are"
    -	MSG="\nThe following camera type${S} connected to the Pi.\n"
    -	MSG="${MSG}Pick the one you want."
    -	MSG="${MSG}\nIf it's not in the list, select <Cancel> and determine why."
    -	CAMERA_TYPE=$(whiptail --title "${TITLE}" --menu "${MSG}" 15 "${WT_WIDTH}" "${NUM}" \
    -		"${CT[@]}" 3>&1 1>&2 2>&3)
    -	if [[ $? -ne 0 ]]; then
    +	S=" is"
    +	[[ ${NUM_CONNECTED_CAMERAS} -gt 1 ]] && S="s are"
    +	MSG="\nThe following camera${S} connected to the Pi.\n"
    +	[[ ${NUM_CONNECTED_CAMERAS} -gt 1 ]] && MSG+="Pick the one you want."
    +	MSG+="\nIf it's not in the list, select <Cancel> and determine why."
    +	if ! CAMERA_INFO=$( whiptail --title "${TITLE}" --notags --menu "${MSG}" 15 "${WT_WIDTH}" \
    +			"${NUM_CONNECTED_CAMERAS}" "${CT[@]}" 3>&1 1>&2 2>&3 ) ; then
     		MSG="Camera selection required."
    -		MSG="${MSG} Please re-run the installation and select a camera to continue."
    +		MSG+=" Please re-run the installation and select a camera to continue."
     		display_msg --log warning "${MSG}"
     		exit_installation 2 "${STATUS_NO_CAMERA}" "User did not select a camera."
     	fi
    +	# CAMERA_INFO is:    number;type;model
    +	CAMERA_NUMBER="${CAMERA_INFO%%;*}"				# before first ";"
    +	CAMERA_MODEL="${CAMERA_INFO##*;}"				# after last ";"
    +	CAMERA_INFO="${CAMERA_INFO/${CAMERA_NUMBER};/}"	# Now:  type;model
    +	CAMERA_TYPE="${CAMERA_INFO%;*}"					# before ";"
     
    -	display_msg --log progress "Using ${CAMERA_TYPE} camera."
    -	STATUS_VARIABLES+=("select_camera_type='true'\n")
    +	display_msg --log progress "Using user-selected ${CAMERA_TYPE} ${CAMERA_MODEL} camera."
     	STATUS_VARIABLES+=("CAMERA_TYPE='${CAMERA_TYPE}'\n")
    -}
    -
    -####
    -# If the raspistill command exists on post-Buster releases,
    -# rename it so it's not used.
    -check_for_raspistill()
    -{
    -	STATUS_VARIABLES+=("check_for_raspistill='true'\n")
    +	STATUS_VARIABLES+=("CAMERA_MODEL='${CAMERA_MODEL}'\n")
    +	STATUS_VARIABLES+=("CAMERA_NUMBER='${CAMERA_NUMBER}'\n")
     
    -	if W="$( which raspistill )" && [[ ${PI_OS} != "buster" ]]; then
    -		display_msg --longonly info "Renaming 'raspistill' on ${PI_OS}."
    -		sudo mv "${W}" "${W}-OLD"
    -	fi
    +	STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
     }
     
    -
     ####
    -# Create the file that defines the WebUI variables.
    -create_webui_defines()
    -{
    -	display_msg --log progress "Modifying locations for WebUI."
    -	FILE="${ALLSKY_WEBUI}/includes/allskyDefines.inc"
    -	sed		-e "s;XX_HOME_XX;${HOME};" \
    -			-e "s;XX_ALLSKY_HOME_XX;${ALLSKY_HOME};" \
    -			-e "s;XX_ALLSKY_CONFIG_XX;${ALLSKY_CONFIG};" \
    -			-e "s;XX_ALLSKY_SCRIPTS_XX;${ALLSKY_SCRIPTS};" \
    -			-e "s;XX_ALLSKY_TMP_XX;${ALLSKY_TMP};" \
    -			-e "s;XX_ALLSKY_IMAGES_XX;${ALLSKY_IMAGES};" \
    -			-e "s;XX_ALLSKY_MESSAGES_XX;${ALLSKY_MESSAGES};" \
    -			-e "s;XX_ALLSKY_WEBUI_XX;${ALLSKY_WEBUI};" \
    -			-e "s;XX_ALLSKY_WEBSITE_XX;${ALLSKY_WEBSITE};" \
    -			-e "s;XX_ALLSKY_WEBSITE_LOCAL_CONFIG_NAME_XX;${ALLSKY_WEBSITE_CONFIGURATION_NAME};" \
    -			-e "s;XX_ALLSKY_WEBSITE_REMOTE_CONFIG_NAME_XX;${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_NAME};" \
    -			-e "s;XX_ALLSKY_WEBSITE_LOCAL_CONFIG_XX;${ALLSKY_WEBSITE_CONFIGURATION_FILE};" \
    -			-e "s;XX_ALLSKY_WEBSITE_REMOTE_CONFIG_XX;${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE};" \
    -			-e "s;XX_ALLSKY_OWNER_XX;${ALLSKY_OWNER};" \
    -			-e "s;XX_ALLSKY_GROUP_XX;${ALLSKY_GROUP};" \
    -			-e "s;XX_WEBSERVER_GROUP_XX;${WEBSERVER_GROUP};" \
    -			-e "s;XX_ALLSKY_REPO_XX;${ALLSKY_REPO};" \
    -			-e "s;XX_ALLSKY_VERSION_XX;${ALLSKY_VERSION};" \
    -			-e "s;XX_RASPI_CONFIG_XX;${ALLSKY_CONFIG};" \
    -			-e "s;XX_ALLSKY_OVERLAY_XX;${ALLSKY_OVERLAY};" \
    -			-e "s;XX_ALLSKY_MODULES_XX;${ALLSKY_MODULES};" \
    -		"${REPO_WEBUI_DEFINES_FILE}"  >  "${FILE}"
    -		chmod 644 "${FILE}"
    -
    -	STATUS_VARIABLES+=("create_webui_defines='true'\n")
    -}
    +# Wrapper function to call do_save_camera_capabilities and exit on error.
    +save_camera_capabilities()
    +{
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
     
    +	do_save_camera_capabilities "${1}"
    +	[[ $? -ne 0 ]] && exit_with_image 1 "${STATUS_ERROR}" "${FUNCNAME[0]} failed."
     
    -####
    -# Recreate the options file.
    -# This can be used after installation if the options file gets hosed.
    -recreate_options_file()
    -{
    -	CAMERA_TYPE="$( get_variable "CAMERA_TYPE" "${ALLSKY_CONFIG}/config.sh" )"
    -	save_camera_capabilities "true"
    -	set_permissions
    +	STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
     }
     
    -
     ####
     # Save the camera capabilities and use them to set the WebUI min, max, and defaults.
     # This will error out and exit if no camera is installed,
     # otherwise it will determine what capabilities the connected camera has,
     # then create an "options" file specific to that camera.
     # It will also create a default camera-specific "settings" file if one doesn't exist.
    -save_camera_capabilities()
    +
    +do_save_camera_capabilities()
     {
     	if [[ -z ${CAMERA_TYPE} ]]; then
     		display_msg --log error "INTERNAL ERROR: CAMERA_TYPE not set in save_camera_capabilities()."
    @@ -448,7 +489,8 @@ save_camera_capabilities()
     	fi
     
     	local OPTIONSFILEONLY="${1}"		# Set to "true" if we should ONLY create the options file.
    -	local FORCE MSG OPTIONSONLY
    +	local FORCE  MSG  OPTIONSONLY  ERR  M  RET
    +	# CAMERA_MODEL is global
     
     	# Create the camera type/model-specific options file and optionally a default settings file.
     	# --cameraTypeOnly tells makeChanges.sh to only change the camera info, then exit.
    @@ -465,79 +507,189 @@ save_camera_capabilities()
     		OPTIONSONLY=" --optionsOnly"
     	else
     		OPTIONSONLY=""
    -		display_msg --log progress "Setting up WebUI options${MSG} for ${CAMERA_TYPE} cameras."
    +		MSG="Setting up WebUI options${MSG} for ${CAMERA_TYPE} cameras."
    +		display_msg --log progress "${MSG}"
     	fi
     
    -	# Restore the prior settings file or camera-specific settings file(s) so
    +	# Restore the prior settings file or camera-specific settings file(s) if present so
     	# the appropriate one can be used by makeChanges.sh.
    -	[[ ${PRIOR_ALLSKY} != "" ]] && restore_prior_settings_file
    +	[[ -n ${PRIOR_SETTINGS_FILE} ]] && restore_prior_settings_file
     
     	display_msg --log progress "Making new settings file '${SETTINGS_FILE}'."
     
    -	MSG="Executing makeChanges.sh${FORCE}${OPTIONSONLY} --cameraTypeOnly"
    -	MSG="${MSG}  ${DEBUG_ARG} 'cameraType' 'Camera Type' '${PRIOR_CAMERA_TYPE}' '${CAMERA_TYPE}'"
    +	CMD="makeChanges.sh${FORCE}${OPTIONSONLY}"
    +	CMD+=" --cameraTypeOnly --fromInstall --addNewSettings ${DEBUG_ARG}"
    +	#shellcheck disable=SC2089
    +	CMD+=" cameranumber 'Camera Number' '${PRIOR_CAMERA_NUMBER}' '${CAMERA_NUMBER}'"
    +	#shellcheck disable=SC2089
    +	CMD+=" cameramodel 'Camera Model' '${PRIOR_CAMERA_MODEL}' '${CAMERA_MODEL}'"
    +
    +	# cameratype needs to come last.
    +	#shellcheck disable=SC2089
    +	CMD+=" cameratype 'Camera Type' '${PRIOR_CAMERA_TYPE}' '${CAMERA_TYPE}'"
    +
    +	MSG="Executing ${CMD}"
     	display_msg "${LOG_TYPE}" info "${MSG}"
     
    -	#shellcheck disable=SC2086
    -	MSG="$( "${ALLSKY_SCRIPTS}/makeChanges.sh" ${FORCE} ${OPTIONSONLY} --cameraTypeOnly \
    -		${DEBUG_ARG} "cameraType" "Camera Type" "${PRIOR_CAMERA_TYPE}" "${CAMERA_TYPE}" 2>&1 )"
    +	local TMP="${ALLSKY_LOGS}/makeChanges.log"
    +	#shellcheck disable=SC2086,SC2090
    +	M="$( eval "${ALLSKY_SCRIPTS}/"${CMD} 2> "${TMP}" )"
     	RET=$?
    -
    -	[[ -n ${MSG} ]] && display_msg "${LOG_TYPE}" info "${MSG}"
     	if [[ ${RET} -ne 0 ]]; then
     		if [[ ${RET} -eq ${EXIT_NO_CAMERA} ]]; then
     			MSG="No camera was found; one must be connected and working for the installation to succeed.\n"
    -			MSG="${MSG}After connecting your camera, re-run the installation."
    +			MSG+="After connecting your camera, re-run the installation."
     			whiptail --title "${TITLE}" --msgbox "${MSG}" 12 "${WT_WIDTH}" 3>&1 1>&2 2>&3
     			display_msg --log error "No camera detected - installation aborted."
    +			[[ -s ${TMP} ]] && display_msg --log error "$( < "${TMP}" )"
     			exit_with_image 1 "${STATUS_ERROR}" "No camera detected"
     		elif [[ ${OPTIONSFILEONLY} == "false" ]]; then
     			display_msg --log error "Unable to save camera capabilities."
    +			[[ -s ${TMP} ]] && display_msg --log error "$( < "${TMP}" )"
    +			[[ -n ${M} ]] && display_msg --log error "${M}"
     		fi
     		return 1
    +	else
    +		[[ -n ${M} ]] && display_msg --logonly info "${M}"
    +
    +		if [[ ! -f ${SETTINGS_FILE} ]]; then
    +			display_msg --log error "Settings file not created; cannot continue."
    +			return 1
    +		fi
     	fi
     
     	#shellcheck disable=SC2012
     	MSG="$( /bin/ls -l "${ALLSKY_CONFIG}/settings"*.json 2>/dev/null | sed 's/^/    /' )"
    -	display_msg "${LOG_TYPE}" info "Settings files:\n${MSG}"
    -	CAMERA_MODEL="$( settings ".cameraModel" "${SETTINGS_FILE}" )"
    +	display_msg --logonly info "Settings files:\n${MSG}"
    +	CAMERA_MODEL="$( settings ".cameramodel" "${SETTINGS_FILE}" )"
     	if [[ -z ${CAMERA_MODEL} ]]; then
    -		display_msg --log warning "cameraModel not found in settings file."
    +		display_msg --log error "cameramodel not found in settings file."
    +		return 1
     	fi
     
    -	STATUS_VARIABLES+=("save_camera_capabilities='true'\n")
     	return 0
     }
     
     
    +####
    +# If the raspistill command exists on post-Buster releases,
    +# rename it so it's not used.
    +check_for_raspistill()
    +{
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
    +	local W
    +
    +	if W="$( which raspistill )" && [[ ${PI_OS} != "buster" ]]; then
    +		display_msg --longonly info "Renaming 'raspistill' on ${PI_OS}."
    +		sudo mv "${W}" "${W}-OLD"
    +	fi
    +
    +	STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
    +}
    +
    +
    +####
    +# Get a count of the number of the specified file in the specified directory.
    +get_count()
    +{
    +	local DIR="${1}"
    +	local FILENAME="${2}"
    +	find "${DIR}" -maxdepth 1 -name "${FILENAME}" | wc -l
    +}
    +
    +
    +####
    +# Update various PHP define() variables.
    +update_php_defines()
    +{
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
    +	[[ ${SKIP} == "true" ]] && return
    +
    +	display_msg --log progress "Modifying variables for WebUI and Website."
    +	local FILE="${ALLSKY_WEBUI}/includes/${ALLSKY_DEFINES_INC}"
    +	sed		-e "s;XX_HOME_XX;${HOME};g" \
    +			-e "s;XX_ALLSKY_HOME_XX;${ALLSKY_HOME};g" \
    +			-e "s;XX_ALLSKY_CONFIG_XX;${ALLSKY_CONFIG};g" \
    +			-e "s;XX_ALLSKY_SCRIPTS_XX;${ALLSKY_SCRIPTS};g" \
    +			-e "s;XX_ALLSKY_TMP_XX;${ALLSKY_TMP};g" \
    +			-e "s;XX_ALLSKY_IMAGES_XX;${ALLSKY_IMAGES};g" \
    +			-e "s;XX_ALLSKY_MESSAGES_XX;${ALLSKY_MESSAGES};g" \
    +			-e "s;XX_ALLSKY_CHECK_ALLSKY_LOG_XX;${CHECK_ALLSKY_LOG};g" \
    +			-e "s;XX_ALLSKY_WEBUI_XX;${ALLSKY_WEBUI};g" \
    +			-e "s;XX_ALLSKY_WEBSITE_XX;${ALLSKY_WEBSITE};g" \
    +			-e "s;XX_ALLSKY_WEBSITE_LOCAL_CONFIG_NAME_XX;${ALLSKY_WEBSITE_CONFIGURATION_NAME};g" \
    +			-e "s;XX_ALLSKY_WEBSITE_REMOTE_CONFIG_NAME_XX;${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_NAME};g" \
    +			-e "s;XX_ALLSKY_WEBSITE_LOCAL_CONFIG_XX;${ALLSKY_WEBSITE_CONFIGURATION_FILE};g" \
    +			-e "s;XX_ALLSKY_WEBSITE_REMOTE_CONFIG_XX;${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE};g" \
    +			-e "s;XX_ALLSKY_OVERLAY_XX;${ALLSKY_OVERLAY};g" \
    +			-e "s;XX_ALLSKY_ENV_XX;${ALLSKY_ENV};g" \
    +			-e "s;XX_MY_OVERLAY_TEMPLATES_XX;${MY_OVERLAY_TEMPLATES};g" \
    +			-e "s;XX_ALLSKY_MODULES_XX;${ALLSKY_MODULES};g" \
    +			-e "s;XX_ALLSKY_MODULE_LOCATION_XX;${ALLSKY_MODULE_LOCATION};g" \
    +			-e "s;XX_ALLSKY_OWNER_XX;${ALLSKY_OWNER};g" \
    +			-e "s;XX_ALLSKY_GROUP_XX;${ALLSKY_GROUP};g" \
    +			-e "s;XX_WEBSERVER_OWNER_XX;${WEBSERVER_OWNER};g" \
    +			-e "s;XX_WEBSERVER_GROUP_XX;${WEBSERVER_GROUP};g" \
    +			-e "s;XX_ALLSKY_REPO_XX;${ALLSKY_REPO};g" \
    +			-e "s;XX_ALLSKY_VERSION_XX;${ALLSKY_VERSION};g" \
    +			-e "s;XX_ALLSKY_STATUS_XX;${ALLSKY_STATUS};g" \
    +			-e "s;XX_RASPI_CONFIG_XX;${ALLSKY_CONFIG};g" \
    +		"${REPO_WEBUI_DEFINES_FILE}"  >  "${FILE}"
    +		chmod 644 "${FILE}"
    +
    +	# Don't save status if we did a fix.
    +	if [[ ${FIX} == "false" ]]; then
    +		STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
    +	fi
    +}
    +
    +
    +####
    +# Recreate the options file.
    +# This can be used after installation if the options file gets hosed.
    +recreate_options_file()
    +{
    +	CAMERA_TYPE="$( settings ".cameratype" )"
    +	save_camera_capabilities "true"
    +	set_permissions
    +}
    +
    +
     ####
     # Update the sudoers file so the web server can execute certain commands with sudo.
     do_sudoers()
     {
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
    +	[[ ${SKIP} == "true" ]] && return
    +
     	display_msg --log progress "Creating/updating sudoers file."
    -	sed -e "s;XX_ALLSKY_SCRIPTS_XX;${ALLSKY_SCRIPTS};" "${REPO_SUDOERS_FILE}"  >  /tmp/x
    -	sudo install -m 0644 /tmp/x "${FINAL_SUDOERS_FILE}" && rm -f /tmp/x
    +	sed -e "s;XX_ALLSKY_SCRIPTS_XX;${ALLSKY_SCRIPTS};" "${REPO_SUDOERS_FILE}"  >  "${TMP_FILE}"
    +	sudo install -m 0644 "${TMP_FILE}" "${FINAL_SUDOERS_FILE}" && rm -f "${TMP_FILE}"
    +
    +	STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
     }
     
     
     ####
    -# Ask the user if they want to reboot
    +# Ask the user if they want to reboot.
    +# Call every time in case they change their mind.
     WILL_REBOOT="false"
     ask_reboot()
     {
     	local TYPE="${1}"
    +	local MSG  AT
     
     	if [[ ${TYPE} == "locale" ]]; then
    -		local MSG="A reboot is needed for the locale change to take effect."
    -		MSG="${MSG}\nYou must reboot before continuing the installation."
    -		MSG="${MSG}\n\nReboot now?"
    +		MSG="A reboot is needed for the locale change to take effect."
    +		MSG+="\nYou must reboot before continuing the installation."
    +		MSG+="\n\nReboot now?"
     		if whiptail --title "${TITLE}" --yesno "${MSG}" 18 "${WT_WIDTH}" 3>&1 1>&2 2>&3; then
     			MSG="\nAfter the reboot you MUST continue with the installation"
    -			MSG="${MSG} before anything will work."
    -			MSG="${MSG}\nTo restart the installation, do the following:\n"
    -			MSG="${MSG}\n   cd ~/allsky"
    -			MSG="${MSG}\n   ./install.sh"
    -			MSG="${MSG}\n\nThe installation will pick up where it left off."
    +			MSG+=" before anything will work."
    +			MSG+="\nTo restart the installation, do the following:\n"
    +			MSG+="\n   cd ~/allsky"
    +			MSG+="\n   ./install.sh"
    +			MSG+="\n\nThe installation will pick up where it left off."
     			whiptail --title "${TITLE}" --msgbox "${MSG}" 15 "${WT_WIDTH}"   3>&1 1>&2 2>&3
     			return 0
     		else
    @@ -546,31 +698,30 @@ ask_reboot()
     		fi
     	fi
     
    -	local AT="     http://${NEW_HOST_NAME}.local\n"
    -	AT="${AT}or\n"
    -	AT="${AT}     http://$(hostname -I | sed -e 's/ .*$//')"
    +	AT="     http://${NEW_HOST_NAME}.local\n"
    +	AT+="or\n"
    +	AT+="     http://$( hostname -I | sed -e 's/ .*$//' )"
     
     	if [[ ${REBOOT_NEEDED} == "false" ]]; then
    -		MSG="\nAfter reboot you can connect to the WebUI at:\n${AT}"
    +		MSG="\nAfter installation you can connect to the WebUI at:\n${AT}"
     		display_msg -log progress "${MSG}"
     		return 0
     	fi
     
    -	local MSG="*** Allsky installation is almost done. ***"
    -	MSG="${MSG}\n\nWhen done, you must reboot the Raspberry Pi to finish the installation."
    -	MSG="${MSG}\n\nAfter reboot you can connect to the WebUI at:\n"
    -	MSG="${MSG}${AT}"
    -	MSG="${MSG}\n\nReboot when installation is done?"
    +	MSG="*** Allsky installation is almost done. ***"
    +	MSG+="\n\nWhen done, you must reboot the Raspberry Pi to finish the installation."
    +	MSG+="\n\nAfter reboot you can connect to the WebUI at:\n"
    +	MSG+="${AT}"
    +	MSG+="\n\nReboot when installation is done?"
     	if whiptail --title "${TITLE}" --yesno "${MSG}" 18 "${WT_WIDTH}" 3>&1 1>&2 2>&3; then
     		WILL_REBOOT="true"
     		display_msg --logonly info "Pi will reboot after installation completes."
     	else
    -		display_msg --logonly info "User elected not to reboot; displayed warning message."
    -		display_msg notice "You need to reboot the Pi before Allsky will work."
    +		display_msg --logonly info "User elected not to reboot."
     
     		MSG="If you have not already rebooted your Pi, please do so now.\n"
    -		MSG="${MSG}You can then connect to the WebUI at:\n"
    -		MSG="${MSG}${AT}"
    +		MSG+="You can then connect to the WebUI at:\n"
    +		MSG+="${AT}"
     		"${ALLSKY_SCRIPTS}/addMessage.sh" "info" "${MSG}"
     	fi
     }
    @@ -581,104 +732,6 @@ do_reboot()
     }
     
     
    -####
    -# Check for size of RAM+swap during installation (Issue # 969).
    -# recheck_swap is used to check swap after the installation,
    -# and is referenced in the Allsky Documentation.
    -recheck_swap()
    -{
    -	check_swap "prompt"
    -}
    -check_swap()
    -{
    -	STATUS_VARIABLES+=("check_swap='true'\n")
    -
    -	local PROMPT="false"
    -	[[ ${1} == "prompt" ]] && PROMPT="true"
    -
    -	# This can return "total_mem is unknown" if the OS is REALLY old.
    -	local RAM_SIZE="$( vcgencmd get_config total_mem )"
    -	if echo "${RAM_SIZE}" | grep --silent "unknown" ; then
    -		# Note: This doesn't produce exact results.  On a 4 GB Pi, it returns 3.74805.
    -		RAM_SIZE=$(free --mebi | awk '{if ($1 == "Mem:") {print $2; exit 0} }')		# in MB
    -	else
    -		RAM_SIZE="${RAM_SIZE//total_mem=/}"
    -	fi
    -	local DESIRED_COMBINATION=$((1024 * 5))		# desired minimum memory + swap
    -	local SUGGESTED_SWAP_SIZE=0
    -	for i in 512 1024 2048 4096		# 8192 and above don't need any swap
    -	do
    -		if [[ ${RAM_SIZE} -le ${i} ]]; then
    -			SUGGESTED_SWAP_SIZE=$((DESIRED_COMBINATION - i))
    -			break
    -		fi
    -	done
    -	display_msg --logonly info "RAM_SIZE=${RAM_SIZE}, SUGGESTED_SWAP_SIZE=${SUGGESTED_SWAP_SIZE}."
    -
    -	# Not sure why, but displayed swap is often 1 MB less than what's in /etc/dphys-swapfile
    -	local CURRENT_SWAP=$(free --mebi | awk '{if ($1 == "Swap:") {print $2 + 1; exit 0} }')	# in MB
    -	CURRENT_SWAP=${CURRENT_SWAP:-0}
    -	if [[ ${CURRENT_SWAP} -lt ${SUGGESTED_SWAP_SIZE} || ${PROMPT} == "true" ]]; then
    -		local SWAP_CONFIG_FILE="/etc/dphys-swapfile"
    -
    -		[[ -z ${FUNCTION} ]] && sleep 2		# give user time to read prior messages
    -		local AMT M
    -		if [[ ${CURRENT_SWAP} -eq 1 ]]; then
    -			CURRENT_SWAP=0
    -			AMT="no"
    -			M="added"
    -		else
    -			AMT="${CURRENT_SWAP} MB of"
    -			M="increased"
    -		fi
    -		MSG="\nYour Pi currently has ${AMT} swap space."
    -		MSG="${MSG}\nBased on your memory size of ${RAM_SIZE} MB,"
    -		if [[ ${CURRENT_SWAP} -ge ${SUGGESTED_SWAP_SIZE} ]]; then
    -			SUGGESTED_SWAP_SIZE=${CURRENT_SWAP}
    -			MSG="${MSG} there is no need to change anything, but you can if you would like."
    -		else
    -			MSG="${MSG} we suggest ${SUGGESTED_SWAP_SIZE} MB of swap"
    -			MSG="${MSG} to decrease the chance of timelapse and other failures."
    -			MSG="${MSG}\n\nDo you want swap space ${M}?"
    -			MSG="${MSG}\n\nYou may change the amount of swap by changing the number below."
    -		fi
    -
    -		local SWAP_SIZE=$(whiptail --title "${TITLE}" --inputbox "${MSG}" 18 "${WT_WIDTH}" \
    -			"${SUGGESTED_SWAP_SIZE}" 3>&1 1>&2 2>&3)
    -		# If the suggested swap was 0 and the user added a number but didn't first delete the 0,
    -		# do it now so we don't have numbers like "0256".
    -		[[ ${SWAP_SIZE:0:1} == "0" ]] && SWAP_SIZE="${SWAP_SIZE:1}"
    -
    -		if [[ -z ${SWAP_SIZE} || ${SWAP_SIZE} == "0" ]]; then
    -			if [[ ${CURRENT_SWAP} -eq 0 && ${SUGGESTED_SWAP_SIZE} -gt 0 ]]; then
    -				display_msg --log warning "With no swap space you run the risk of programs failing."
    -			else
    -				display_msg --log info "Swap will remain at ${CURRENT_SWAP}."
    -			fi
    -		else
    -			display_msg --log progress "Setting swap space to ${SWAP_SIZE} MB."
    -			sudo dphys-swapfile swapoff					# Stops the swap file
    -			sudo sed -i "/CONF_SWAPSIZE/ c CONF_SWAPSIZE=${SWAP_SIZE}" "${SWAP_CONFIG_FILE}"
    -
    -			local CURRENT_MAX="$(get_variable "CONF_MAXSWAP" "${SWAP_CONFIG_FILE}")"
    -			# TODO: Can we determine the default max rather than hard-code it.
    -			CURRENT_MAX="${CURRENT_MAX:-2048}"
    -			if [[ ${CURRENT_MAX} -lt ${SWAP_SIZE} ]]; then
    -				if [[ ${DEBUG} -gt 0 ]]; then
    -					display_msg --log debug "Increasing max swap size to ${SWAP_SIZE} MB."
    -				fi
    -				sudo sed -i "/CONF_MAXSWAP/ c CONF_MAXSWAP=${SWAP_SIZE}" "${SWAP_CONFIG_FILE}"
    -			fi
    -
    -			sudo dphys-swapfile setup  > /dev/null		# Sets up new swap file
    -			sudo dphys-swapfile swapon					# Turns on new swap file
    -		fi
    -	else
    -		display_msg --log progress "Size of current swap (${CURRENT_SWAP} MB) is sufficient; no change needed."
    -	fi
    -}
    -
    -
     ####
     # Check if ${ALLSKY_TMP} exists, and if it does,
     # save any *.jpg files (which we probably created), then remove everything else,
    @@ -688,105 +741,56 @@ check_and_mount_tmp()
     	local TMP_DIR="/tmp/IMAGES"
     
     	if [[ -d "${ALLSKY_TMP}" ]]; then
    -		local IMAGES="$(find "${ALLSKY_TMP}" -name '*.jpg')"
    -		if [[ -n ${IMAGES} ]]; then
    -			mkdir "${TMP_DIR}"
    -			# Need to allow for files with spaces in their names.
    -			# TODO: there has to be a better way.
    -			echo "${IMAGES}" | \
    -				while read -r image
    -				do
    -					mv "${image}" "${TMP_DIR}"
    -				done
    -		fi
    -		rm -f "${ALLSKY_TMP}"/*
    +		mkdir -p "${TMP_DIR}"
    +		find "${ALLSKY_TMP}" \( -name '*.jpg' -o -name '*.png' \) -exec mv '{}' "${TMP_DIR}" \;
    +		rm -fr "${ALLSKY_TMP:?}"/*
     	else
     		mkdir "${ALLSKY_TMP}"
     	fi
     
     	# Now mount and restore any images that were there before
     	sudo systemctl daemon-reload 2> /dev/null
    -	sudo mount -a
    +	sudo mount -a || display_msg --log warning "Unable to mount '${ALLSKY_TMP}'."
    +
     	if [[ -d ${TMP_DIR} ]]; then
    -		mv "${TMP_DIR}"/* "${ALLSKY_TMP}"
    +		mv "${TMP_DIR}"/* "${ALLSKY_TMP}" 2>/dev/null
     		rmdir "${TMP_DIR}"
     	fi
     }
     
     
     ####
    -# Check if prior ${ALLSKY_TMP} was a memory filesystem.
    -# If not, offer to make it one.
    -check_tmp()
    +# Run apt-get, first checking if it's locked.
    +run_aptGet()
     {
    -	local INITIAL_FSTAB_STRING="tmpfs ${ALLSKY_TMP} tmpfs"
    -
    -	# Check if currently a memory filesystem.
    -	if grep --quiet "^${INITIAL_FSTAB_STRING}" /etc/fstab; then
    -		MSG="${ALLSKY_TMP} is currently a memory filesystem; no change needed."
    -		display_msg --log progress "${MSG}"
    -
    -		# If there's a prior Allsky version and it's tmp directory is mounted,
    -		# try to unmount it, but that often gives an error that it's busy,
    -		# which isn't really a problem since it'll be unmounted at the reboot.
    -		# We know from the grep above that /etc/fstab has ${ALLSKY_TMP}
    -		# but the mount point is currently in the PRIOR Allsky.
    -		local D="${PRIOR_ALLSKY_DIR}/tmp"
    -		if [[ -d "${D}" ]] && mount | grep --silent "${D}" ; then
    -			# The Samba daemon is one known cause of "target busy".
    -			sudo umount -f "${D}" 2> /dev/null ||
    -				(
    -					sudo systemctl restart smbd 2> /dev/null
    -					sudo umount -f "${D}" 2> /dev/null
    -				)
    -		fi
    -
    -		STATUS_VARIABLES+=("check_tmp='true'\n")
    -
    -		# If the new Allsky's ${ALLSKY_TMP} is already mounted, don't do anything.
    -		# This would be the case during an upgrade.
    -		if mount | grep --silent "${ALLSKY_TMP}" ; then
    -			display_msg --logonly info "${ALLSKY_TMP} already mounted."
    -			return 0
    -		fi
    -
    -		check_and_mount_tmp		# works on new ${ALLSKY_TMP}
    -		return 0
    -	fi
    +	local NUM_FAILS=0
     
    -	local SIZE=75		# MB - should be enough
    -	MSG="Making ${ALLSKY_TMP} reside in memory can drastically decrease the amount of writes to the SD card, increasing its life."
    -	MSG="${MSG}\n\nDo you want to make it reside in memory?"
    -	MSG="${MSG}\n\nNote: anything in it will be deleted whenever the Pi is rebooted, but that's not an issue since the directory only contains temporary files."
    -	if whiptail --title "${TITLE}" --yesno "${MSG}" 15 "${WT_WIDTH}"  3>&1 1>&2 2>&3; then
    -		local STRING="${INITIAL_FSTAB_STRING} size=${SIZE}M,noatime,lazytime,nodev,nosuid,mode=775,uid=${ALLSKY_OWNER},gid=${WEBSERVER_GROUP}"
    -		if ! echo "${STRING}" | sudo tee -a /etc/fstab > /dev/null ; then
    -			display_msg --log error "Unable to update /etc/fstab"
    +	while sudo fuser --silent "/var/lib/dpkg/lock-frontend" ;
    +	do
    +		(( NUM_FAILS++ ))
    +		if [[ ${NUM_FAILS} -eq 5 ]]; then
    +			echo "apt-get is locked.  Tried 5 times." >&2
    +			echo "Wait a while and try the Allsky installation again." >&2
     			return 1
     		fi
    -		check_and_mount_tmp
    -		display_msg --log progress "${ALLSKY_TMP} is now in memory."
    -	else
    -		display_msg --log info "${ALLSKY_TMP} will remain on disk."
    -		mkdir -p "${ALLSKY_TMP}"
    -	fi
    -
    -	STATUS_VARIABLES+=("check_tmp='true'\n")
    +		sleep 3
    +	done
    +	sudo apt-get --assume-yes install "${@}"
     }
     
    -
     ####
    +# If the return code -ne 0
     check_success()
     {
     	local RET=${1}
     	local MESSAGE="${2}"
     	local LOG="${3}"
     	local D=${4}
    +	local MSG
     
     	if [[ ${RET} -ne 0 ]]; then
     		display_msg --log error "${MESSAGE}"
    -		MSG="The full log file is in ${LOG}"
    -		MSG="${MSG}\nThe end of the file is:"
    +		MSG="The full log file is in ${LOG}\nThe end of the file is:"
     		display_msg --log info "${MSG}"
     		indent "$( tail "${LOG}" )"
     
    @@ -798,57 +802,55 @@ check_success()
     }
     
     
    +####
    +# Get checksums of local Website before the user changes anything.
    +# We don't use this but it's used if the user installs a remote Website.
    +get_checksums()
    +{
    +	declare -n v="${FUNCNAME[0]}"
    +
    +	[[ -s ${ALLSKY_WEBSITE_CHECKSUM_FILE} ]] && return
    +	get_website_checksums > "${ALLSKY_WEBSITE_CHECKSUM_FILE}"
    +
    +	STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
    +}
    +
    +
     ####
     # Install the web server.
     install_webserver_et_al()
     {
    -	sudo systemctl stop hostapd 2> /dev/null
    -	sudo systemctl stop lighttpd 2> /dev/null
    +	declare -n v="${FUNCNAME[0]}"
    +	[[ ${SKIP} == "true" ]] && return
    +
    +	sudo systemctl stop hostapd 2>/dev/null
    +	sudo systemctl stop lighttpd 2>/dev/null
     
    -	if [[ ${install_webserver_et_al} == "true" ]]; then
    +	if [[ ${v} == "true" ]]; then
    +		# Already installed it; just configure it.
     		display_msg --log progress "Preparing the web server."
     	else
     		display_msg --log progress "Installing the web server."
    -		TMP="${ALLSKY_INSTALLATION_LOGS}/lighttpd.install.log"
    -		(
    -			sudo apt-get update && \
    -				sudo apt-get --assume-yes install lighttpd php-cgi php-gd hostapd dnsmasq avahi-daemon
    -		) > "${TMP}" 2>&1
    -		if ! check_success $? "lighttpd installation failed" "${TMP}" "${DEBUG}" ; then
    +		TMP="${ALLSKY_LOGS}/lighttpd.install.log"
    +		run_aptGet lighttpd php-cgi php-gd hostapd dnsmasq avahi-daemon hwinfo > "${TMP}" 2>&1
    +		check_success $? "lighttpd installation failed" "${TMP}" "${DEBUG}" ||
     			exit_with_image 1 "${STATUS_ERROR}" "lighttpd installation failed"
    -		fi
    -
    -		FINAL_LIGHTTPD_FILE="/etc/lighttpd/lighttpd.conf"
    -		sed \
    -			-e "s;XX_ALLSKY_WEBUI_XX;${ALLSKY_WEBUI};g" \
    -			-e "s;XX_ALLSKY_HOME_XX;${ALLSKY_HOME};g" \
    -			-e "s;XX_ALLSKY_IMAGES_XX;${ALLSKY_IMAGES};g" \
    -			-e "s;XX_ALLSKY_CONFIG_XX;${ALLSKY_CONFIG};g" \
    -			-e "s;XX_ALLSKY_WEBSITE_XX;${ALLSKY_WEBSITE};g" \
    -			-e "s;XX_ALLSKY_OVERLAY_XX;${ALLSKY_OVERLAY};g" \
    -			-e "s;XX_ALLSKY_DOCUMENTATION_XX;${ALLSKY_DOCUMENTATION};g" \
    -				"${REPO_LIGHTTPD_FILE}"  >  /tmp/x
    -		sudo install -m 0644 /tmp/x "${FINAL_LIGHTTPD_FILE}" && rm -f /tmp/x
     	fi
     
    +	create_lighttpd_config_file
    +	create_lighttpd_log_file
    +
     	# Ignore output since it may already be enabled.
     	sudo lighty-enable-mod fastcgi-php > /dev/null 2>&1
     
    -	# Remove any old log files.
    -	# Start off with a 0-length log file the user can write to.
    -	local D="/var/log/lighttpd"
    -	sudo chmod 755 "${D}"
    -	sudo rm -fr "${D}"/*
    -	local LIGHTTPD_LOG="${D}/error.log"
    -	sudo touch "${LIGHTTPD_LOG}"
    -	sudo chmod 664 "${LIGHTTPD_LOG}"
    -	sudo chown "${WEBSERVER_GROUP}:${ALLSKY_GROUP}" "${LIGHTTPD_LOG}"
    -
    -	sudo systemctl start lighttpd
    +	TMP="${ALLSKY_LOGS}/lighttpd.start.log"
    +	#shellcheck disable=SC2024
    +	sudo systemctl start lighttpd > "${TMP}" 2>&1
    +	check_success $? "Unable to start lighttpd" "${TMP}" "${DEBUG}"
     	# Starting it added an entry so truncate the file so it's 0-length
    -	sleep 1; truncate -s 0 "${LIGHTTPD_LOG}"
    +	sleep 1; truncate -s 0 "${LIGHTTPD_LOG_FILE}"
     
    -	STATUS_VARIABLES+=("install_webserver_et_al='true'\n")
    +	STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
     }
     
     
    @@ -861,34 +863,35 @@ install_webserver_et_al()
     
     prompt_for_hostname()
     {
    -	local CURRENT_HOSTNAME=$(tr -d " \t\n\r" < /etc/hostname)
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
    +
    +	local CURRENT_HOSTNAME=$( tr -d " \t\n\r" < /etc/hostname )
     	if [[ ${CURRENT_HOSTNAME} != "raspberrypi" ]]; then
     		display_msg --logonly info "Using current hostname of '${CURRENT_HOSTNAME}'."
     		NEW_HOST_NAME="${CURRENT_HOSTNAME}"
     
    -		STATUS_VARIABLES+=("prompt_for_hostname='true'\n")
    +		STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
     		STATUS_VARIABLES+=("NEW_HOST_NAME='${NEW_HOST_NAME}'\n")
     		return
     	fi
     
     	MSG="Please enter a hostname for your Pi."
    -	MSG="${MSG}\n\nIf you have more than one Pi on your network they MUST all have unique names."
    -	MSG="${MSG}\n\nThe current hostname is '${CURRENT_HOSTNAME}'; the suggested name is below:\n"
    -	NEW_HOST_NAME=$(whiptail --title "${TITLE}" --inputbox "${MSG}" 15 "${WT_WIDTH}" \
    -		"${SUGGESTED_NEW_HOST_NAME}" 3>&1 1>&2 2>&3)
    +	MSG+="\n\nIf you have more than one Pi on your network they MUST all have unique names."
    +	MSG+="\n\nThe current hostname is '${CURRENT_HOSTNAME}'; the suggested name is below:\n"
    +	NEW_HOST_NAME=$( whiptail --title "${TITLE}" --inputbox "${MSG}" 15 "${WT_WIDTH}" \
    +		"${SUGGESTED_NEW_HOST_NAME}" 3>&1 1>&2 2>&3 )
     	if [[ $? -ne 0 ]]; then
     		MSG="You must specify a host name."
    -		MSG="${MSG}  Please re-run the installation and select one."
    +		MSG+="  Please re-run the installation and select one."
     		display_msg --log warning "${MSG}"
     		exit_installation 2 "No host name selected"
    -	else
    -		STATUS_VARIABLES+=("prompt_for_hostname='true'\n")
    -		STATUS_VARIABLES+=("NEW_HOST_NAME='${NEW_HOST_NAME}'\n")
     	fi
     
    +	STATUS_VARIABLES+=("NEW_HOST_NAME='${NEW_HOST_NAME}'\n")
    +
     	if [[ ${CURRENT_HOSTNAME} != "${NEW_HOST_NAME}" ]]; then
     		echo "${NEW_HOST_NAME}" | sudo tee /etc/hostname > /dev/null
    -		sudo sed -i "s/127.0.1.1.*${CURRENT_HOSTNAME}/127.0.1.1\t${NEW_HOST_NAME}/" /etc/hosts
    +		sudo sed -i "s/127.0.1.1.*${CURRENT_HOSTNAME}/127.0.1.1${TAB}${NEW_HOST_NAME}/" /etc/hosts
     
     	# else, they didn't change the default name, but that's their problem...
     	fi
    @@ -901,46 +904,39 @@ prompt_for_hostname()
     		# so need to configure it.
     		display_msg --log progress "Configuring avahi-daemon."
     
    -		sed "s/XX_HOST_NAME_XX/${NEW_HOST_NAME}/g" "${REPO_AVI_FILE}" > /tmp/x
    -		sudo install -m 0644 /tmp/x "${FINAL_AVI_FILE}" && rm -f /tmp/x
    +		sed "s/XX_HOST_NAME_XX/${NEW_HOST_NAME}/g" "${REPO_AVI_FILE}" > "${TMP_FILE}"
    +		sudo install -m 0644 "${TMP_FILE}" "${FINAL_AVI_FILE}" && rm -f "${TMP_FILE}"
     	fi
    +
    +	STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
     }
     
     
     ####
     # Set permissions on various web-related items.
    +# Do every time - doesn't hurt to re-do them.
     set_permissions()
     {
    -	display_msg --log progress "Setting permissions on web-related files."
    -
    -	# Make sure the currently running user has can write to the webserver root
    -	# and can run sudo on anything.
    -	G="$(id "${ALLSKY_OWNER}")"
    -
    -	if ! echo "${G}" | grep --silent "(sudo)"; then
    -		display_msg --log progress "Adding ${ALLSKY_OWNER} to sudo group."
    -
    -		### TODO:  Hmmm.  We need to run "sudo" to add to the group,
    -		### but we don't have "sudo" permissions yet... so this will likely fail:
    -
    -		sudo adduser --quiet "${ALLSKY_OWNER}" "sudo"
    -	fi
    -
    -	if ! echo "${G}" | grep --silent "(${WEBSERVER_GROUP})"; then
    -		display_msg --log progress "Adding ${ALLSKY_OWNER} to ${WEBSERVER_GROUP} group."
    -		sudo adduser --quiet "${ALLSKY_OWNER}" "${WEBSERVER_GROUP}"
    -
    -		# TODO: We had a case where the login shell wasn't in the group after "adduser"
    -		# until the user logged out and back in.
    -		# And this was AFTER he ran install.sh and rebooted.
    -		# Not sure what to do about this...
    -	fi
    -
    -	# Remove any old entries; we now use /etc/sudoers.d/allsky instead of /etc/sudoers.
    -	# TODO: Can remove this in the next release
    -	sudo sed -i -e "/allsky/d" -e "/${WEBSERVER_GROUP}/d" /etc/sudoers
    +	display_msg --log progress "Setting permissions on various files."
    +
    +	# Make sure the currently running user is in the right groups.
    +	# "sudo" allows them to run sudo on anything.
    +	# "${WEBSERVER_GROUP}" allows the web server to write files to Allsky directories.
    +	# "video" allows the user to access video devices
    +	local G="$( id "${ALLSKY_OWNER}" )"
    +	for g in "sudo" "${WEBSERVER_GROUP}" "video"
    +	do
    +		#shellcheck disable=SC2076
    +		if ! [[ ${G} =~ "(${g})" ]]; then
    +			display_msg --log progress "Adding ${ALLSKY_OWNER} to ${g} group."
    +			sudo adduser --quiet "${ALLSKY_OWNER}" "${g}"
    +		fi
    +	done
     
    -	do_sudoers
    +	# These directories aren't in GitHub so need to be manually created.
    +	mkdir -p \
    +		"${ALLSKY_EXTRA}" \
    +		"${ALLSKY_MYFILES_DIR}"
     
     	# The web server needs to be able to create and update many of the files in ${ALLSKY_CONFIG}.
     	# Not all, but go ahead and chgrp all of them so we don't miss any new ones.
    @@ -948,23 +944,58 @@ set_permissions()
     	sudo find "${ALLSKY_CONFIG}/" -type d -exec chmod 775 '{}' \;
     	sudo chgrp -R "${WEBSERVER_GROUP}" "${ALLSKY_CONFIG}"
     
    +	# Modules and overlays
    +	sudo mkdir -p "${ALLSKY_MODULE_LOCATION}/modules"
    +	sudo chgrp -R "${WEBSERVER_GROUP}" "${ALLSKY_MODULE_LOCATION}"
    +	sudo chmod -R 775 "${ALLSKY_MODULE_LOCATION}"
    +
     	# The files should already be the correct permissions/owners, but just in case, set them.
     	# We don't know what permissions may have been on the old website, so use "sudo".
     	sudo find "${ALLSKY_WEBUI}/" -type f -exec chmod 644 '{}' \;
     	sudo find "${ALLSKY_WEBUI}/" -type d -exec chmod 755 '{}' \;
    -	chmod 755 "${ALLSKY_WEBUI}/includes/createAllskyOptions.php"
    -
    -	if [[ -d "${ALLSKY_WEBSITE}" ]]; then
    -		sudo find "${ALLSKY_WEBUI}/" -type d -name thumbnails \! -perm 775 -exec chmod 775 '{}' \;
    -		sudo find "${ALLSKY_WEBUI}/" -type d -name thumbnails \! -group "${WEBSERVER_GROUP}" -exec chgrp "${WEBSERVER_GROUP}" '{}' \;
    -	fi
     
    +	# Exceptions to files at 644:
     	chmod 775 "${ALLSKY_TMP}"
     	sudo chgrp "${WEBSERVER_GROUP}" "${ALLSKY_TMP}"
     
    -	# This is actually an Allsky Website file, but in case we restored the old website,
    -	# set its permissions.
    -	chgrp -f "${WEBSERVER_GROUP}" "${ALLSKY_WEBSITE_CONFIGURATION_FILE}"
    +	#### Website files
    +
    +	chmod 664 "${ALLSKY_ENV}"
    +	sudo chgrp "${WEBSERVER_GROUP}" "${ALLSKY_ENV}"
    +
    +	# These directories aren't in GitHub so need to be manually created.
    +	mkdir -p \
    +		"${ALLSKY_WEBSITE_MYFILES_DIR}" \
    +		"${ALLSKY_WEBSITE}/videos/thumbnails" \
    +		"${ALLSKY_WEBSITE}/keograms/thumbnails" \
    +		"${ALLSKY_WEBSITE}/startrails/thumbnails"
    +
    +	# Not everything in the Website needs to be writable by the web server,
    +	# but make them all that way so we don't worry about missing something.
    +	sudo find "${ALLSKY_WEBSITE}" -type d -exec chmod 775 '{}' \;
    +	sudo find "${ALLSKY_WEBSITE}" -type f -exec chmod 664 '{}' \;
    +	sudo chgrp --recursive "${WEBSERVER_GROUP}" "${ALLSKY_WEBSITE}"
    +
    +	# Get the session handler type from th ephp ini file
    +	SESSION_HANDLER="$( get_php_setting "session.save_handler" )"
    +	# We need to make changes if the handler is using the filesystem
    +	if [[ ${SESSION_HANDLER} == "files" ]]; then
    +		# Get the path to the php sessions
    +		SESSION_PATH="$( get_php_setting "session.save_path" )"
    +
    +		# Loop over all files in the session folder and if any are not owned by the
    +		# web server user then changs ALL of the php sessions to be owned by the
    +		# web server user
    +		sudo find "${SESSION_PATH}" -type f -print0 | while read -r -d $'\0' SESSION_FILE
    +		do
    +			OWNER="$( sudo stat -c '%U' "${SESSION_FILE}" )"
    +			if [[ ${OWNER} != "${WEBSERVER_OWNER}" ]]; then
    +				display_msg --log info "Found php sessions with wrong owner - fixing them"
    +				sudo chown -R "${WEBSERVER_OWNER}":"${WEBSERVER_OWNER}" "${SESSION_PATH}"
    +				break        
    +			fi
    +		done
    +	fi
     }
     
     
    @@ -976,21 +1007,22 @@ set_permissions()
     OLD_WEBUI_LOCATION_EXISTS_AT_START="false"
     does_old_WebUI_location_exist()
     {
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
    +
     	[[ -d ${OLD_WEBUI_LOCATION} ]] && OLD_WEBUI_LOCATION_EXISTS_AT_START="true"
     
    -	STATUS_VARIABLES+=("does_old_WebUI_location_exist='true'\n")
     	STATUS_VARIABLES+=("OLD_WEBUI_LOCATION_EXISTS_AT_START='${OLD_WEBUI_LOCATION_EXISTS_AT_START}'\n")
    +	STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
     }
     
    -# If the old WebUI location is there:
    -#	but it wasn't when the installation started,
    -#	that means the installation created it so remove it.
    +# If the old WebUI location is there but it wasn't when the installation started,
    +# that means the installation created it so remove it.
     #
    -#	Let the user know if there's an old WebUI, or something unknown there.
    +# Let the user know if there's an old WebUI, or something unknown there.
     
     check_old_WebUI_location()
     {
    -	STATUS_VARIABLES+=("check_old_WebUI_location='true'\n")
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
     
     	[[ ! -d ${OLD_WEBUI_LOCATION} ]] && return
     
    @@ -1000,10 +1032,13 @@ check_old_WebUI_location()
     		return
     	fi
     
    -	# The installation of the web server often creates a file in
    +	MSG="Checking old WebUI location at ${OLD_WEBUI_LOCATION}."
    +	display_msg --log progress "${MSG}"
    +
     	# ${OLD_WEBUI_LOCATION}.  It just says "No files yet...", so delete it.
     	sudo rm -f "${OLD_WEBUI_LOCATION}/index.lighttpd.html"
     
    +	# The installation of the web server often creates a file in
     	if [[ ! -d ${OLD_WEBUI_LOCATION}/includes ]]; then
     		local COUNT=$( find "${OLD_WEBUI_LOCATION}" | wc -l )
     		if [[ ${COUNT} -eq 1 ]]; then
    @@ -1012,157 +1047,26 @@ check_old_WebUI_location()
     			display_msg --logonly info "Deleted empty '${OLD_WEBUI_LOCATION}'."
     		else
     			MSG="The old WebUI location '${OLD_WEBUI_LOCATION}' exists"
    -			MSG="${MSG} but doesn't contain a valid WebUI."
    -			MSG="${MSG}\nPlease check it out after installation - if there's nothing you"
    -			MSG="${MSG} want in it, remove it:  sudo rm -fr '${OLD_WEBUI_LOCATION}'"
    +			MSG+=" but doesn't contain a valid WebUI."
    +			MSG+="\nPlease check it out after installation - if there's nothing you"
    +			MSG+=" want in it, remove it:  sudo rm -fr '${OLD_WEBUI_LOCATION}'"
     			whiptail --title "${TITLE}" --msgbox "${MSG}" 15 "${WT_WIDTH}"   3>&1 1>&2 2>&3
     			display_msg --log notice "${MSG}"
     
    -			echo -e "\n\n==========\n${MSG}" >> "${POST_INSTALLATION_ACTIONS}"
    +			echo -e "\n\n========== ACTION NEEDED:\n${MSG}" >> "${POST_INSTALLATION_ACTIONS}"
     		fi
     		return
     	fi
     
     	MSG="An old version of the WebUI was found in ${OLD_WEBUI_LOCATION};"
    -	MSG="${MSG} it is no longer being used so you may remove it after intallation."
    -	MSG="${MSG}\n\nWARNING: if you have any other web sites in that directory,"
    -	MSG="${MSG}\n\n they will no longer be accessible via the web server."
    +	MSG+=" it is no longer being used so you may remove it after intallation."
    +	MSG+="\n\nWARNING: if you have any other web sites in that directory,"
    +	MSG+="\n\n they will no longer be accessible via the web server."
     	whiptail --title "${TITLE}" --msgbox "${MSG}" 15 "${WT_WIDTH}"   3>&1 1>&2 2>&3
     	display_msg --log notice "${MSG}"
    -	echo -e "\n\n==========\n${MSG}" >> "${POST_INSTALLATION_ACTIONS}"
    -}
    -
    -
    -####
    -# If a website exists, see if it's the newest version.  If not, let the user know.
    -# If it's a new-style website, copy to the new Allsky release directory.
    -handle_prior_website()
    -{
    -	STATUS_VARIABLES+=( "handle_prior_website='true'\n" )
    -	# No variables to add to STATUS_VARIABLES.
    -
    -	local PRIOR_SITE=""
    -	local PRIOR_STYLE=""
    -
    -	local OLD_WEBSITE="${OLD_WEBSITE_LOCATION}"
    -	if [[ -d ${OLD_WEBSITE} ]]; then
    -		PRIOR_SITE="${OLD_WEBSITE}"						# old-style Website
    -		PRIOR_STYLE="old"
    -
    -	elif [[ -d ${PRIOR_ALLSKY_DIR}/html/allsky ]]; then
    -		PRIOR_SITE="${PRIOR_ALLSKY_DIR}/html/allsky"	# new-style Website
    -		PRIOR_STYLE="new"
    -
    -	else
    -		return											# no prior Website
    -	fi
    -
    -	# Move any prior ALLSKY_WEBSITE to the new location.
    -	# This HAS to be done since the lighttpd server only looks in the new location.
    -	# Note: This MUST come before the old WebUI check below so we don't remove the prior website
    -	# when we remove the prior WebUI.
    -
    -	if [[ -d ${ALLSKY_WEBSITE} ]]; then
    -		# Hmmm.  There's prior webite AND a new one.
    -		# Allsky doesn't ship with the website directory, so not sure how one got there...
    -		# Try to remove the new one - if it's not empty the remove will fail
    -		# so rename it.
    -		if ! rmdir "${ALLSKY_WEBSITE}" ; then
    -			local UNKNOWN_WEBSITE="${ALLSKY_WEBSITE}-UNKNOWN-$$"
    -			MSG="Unknown Website in '${ALLSKY_WEBSITE}' is not empty."
    -			MSG="${MSG}\nRenaming to '${UNKNOWN_WEBSITE}'."
    -			display_msg --log error "${MSG}"
    -			if ! mv "${ALLSKY_WEBSITE}" "${UNKNOWN_WEBSITE}" ; then
    -				display_msg --log error "Unable to move."
    -			fi
    -		fi
    -	fi
    -
    -	# Trailing "/" tells get_version to fill in the file
    -	# name given the directory we pass to them.
    -
    -	# If there's no prior website version, then there IS a newer version available.
    -	# Set ${PV} to a string to display in messages, but we'll still use ${PRIOR_VERSION}
    -	# to determine whether or not there's a newer version.  PRIOR_VERSION="" means there is.
    -	local PRIOR_VERSION="$( get_version "${PRIOR_SITE}/" )"
    -	local PV=""
    -	if [[ -z ${PRIOR_VERSION} ]]; then
    -		PV="** Unknown, but old **"
    -	else
    -		PV="${PRIOR_VERSION}"
    -	fi
    -
    -	local NEWEST_VERSION="$(get_Git_version "${GITHUB_MAIN_BRANCH}" "${GITHUB_WEBSITE_PACKAGE}")"
    -	if [[ -z ${NEWEST_VERSION} ]]; then
    -		display_msg --log warning "Unable to determine version of GitHub's Website branch '${GITHUB_MAIN_BRANCH}'."
    -	fi
    -
    -	local B=""
    -
    -	# Check if the prior website is outdated.
    -	# For new-style websites, only check the branch they are currently running.
    -	# If a non-production branch is used the Website installer will check if there's
    -	# a newer production branch.
    -
    -	if [[ ${PRIOR_STYLE} == "new" ]]; then
    -		# If get_branch() returns "" assume prior branch is ${GITHUB_MAIN_BRANCH}.
    -		local PRIOR_BRANCH="$( get_branch "${PRIOR_SITE}" )"
    -		PRIOR_BRANCH="${PRIOR_BRANCH:-${GITHUB_MAIN_BRANCH}}"
    -
    -		display_msg --log progress "Restoring local Allsky Website from ${PRIOR_SITE}."
    -		sudo mv "${PRIOR_SITE}" "${ALLSKY_WEBSITE}"
    -
    -		# Update "AllskyVersion" if needed.
    -		local FIELD=".config.AllskyVersion"
    -		local V
    -		if V="$( settings "${FIELD}" "${ALLSKY_WEBSITE_CONFIGURATION_FILE}" )"; then
    -			if [[ ${V} == "${ALLSKY_VERSION}" ]]; then
    -				display_msg --logonly info "Prior local Website's AllskyVersion already at '${ALLSKY_VERSION}'"
    -			else
    -				MSG="Updating local Website's AllskyVersion from '${V}' to '${ALLSKY_VERSION}'"
    -				display_msg --log progress "${MSG}"
    -				update_json_file "${FIELD}" "${ALLSKY_VERSION}" \
    -					"${ALLSKY_WEBSITE_CONFIGURATION_FILE}"
    -			fi
    -		else
    -			echo "Unable to get ${FIELD} from '${ALLSKY_WEBSITE_CONFIGURATION_FILE}'"
    -		fi
    -
    -		# We can only check Website versions if we obtained the new Website version.
    -		[[ -z ${NEWEST_VERSION} ]] && return
    -
    -		# If the old Website was using a non-production branch,
    -		# see if there's a newer version of the Website with that branch OR
    -		# a newer version with the production branch.  Use whichever is newer.
    -		if [[ -n ${PRIOR_BRANCH} && ${PRIOR_BRANCH} != "${GITHUB_MAIN_BRANCH}" ]]; then
    -			NEWEST_VERSION="$( get_Git_version "${PRIOR_BRANCH}" "${GITHUB_WEBSITE_PACKAGE}" )"
    -			B=" in the '${PRIOR_BRANCH}' branch"
    -
    -			if [[ ${DEBUG} -gt 0 ]]; then
    -				MSG="'${PRIOR_BRANCH}' branch: prior Website version=${PV},"
    -				MSG="${MSG} Git version=${NEWEST_VERSION}."
    -				display_msg --log debug "${MSG}"
    -			fi
    -		fi
    -
    -	elif [[ -z ${NEWEST_VERSION} ]]; then
    -		return
    -	fi
    -
    -	if [[ -n ${NEWEST_VERSION} ]]; then
    -		if [[ -z ${PRIOR_VERSION} || ${PRIOR_VERSION} < "${NEWEST_VERSION}" ]]; then
    -			MSG="There is a newer Allsky Website available${B}; please upgrade to it."
    -			MSG="${MSG}\nYour    version: ${PV}"
    -			MSG="${MSG}\nCurrent version: ${NEWEST_VERSION}"
    -			MSG="${MSG}\n\nYou can upgrade by executing:"
    -			MSG="${MSG}\n     cd ~/allsky; website/install.sh"
    -			MSG="${MSG}\nafter this installation finishes."
    -			display_msg --log notice "${MSG}"
    -			echo -e "\n\n==========\n${MSG}" >> "${POST_INSTALLATION_ACTIONS}"
    -		else
    -			display_msg "${LOG_TYPE}" info "Prior local Website already at ${NEWEST_VERSION}${B}"
    -		fi
    -	fi
    +	echo -e "\n\n========== ACTION NEEDED:\n${MSG}" >> "${POST_INSTALLATION_ACTIONS}"
    +
    +	STATUS_VARIABLES+=( "${FUNCNAME[0]}='true'\n" )
     }
     
     
    @@ -1172,22 +1076,24 @@ DESIRED_LOCALE=""
     CURRENT_LOCALE=""
     get_desired_locale()
     {
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
    +
     	# A lot of people have the incorrect locale so prompt for the correct one.
     
     	# List of all installed locales, ignoring any lines with ":" which
     	# are usually error messages.
    -	local INSTALLED_LOCALES="$(locale -a 2>/dev/null | grep -E -v "^C$|:" | sed 's/utf8/UTF-8/')"
    +	local INSTALLED_LOCALES="$( locale -a 2>/dev/null | grep -E -v "^C$|:" | sed 's/utf8/UTF-8/' )"
     	if [[ -z ${INSTALLED_LOCALES} ]]; then
     		MSG="There are no locales on your system ('locale -a' didn't return valid locales)."
    -		MSG="${MSG}\nYou need to install and set one before Allsky installation can run."
    -		MSG="${MSG}\nTo install locales, run:"
    -		MSG="${MSG}\n\tsudo raspi-config"
    -		MSG="${MSG}\n\t\tPick 'Localisation Options'"
    -		MSG="${MSG}\n\t\tPick 'Locale'"
    -		MSG="${MSG}\n\t\tScroll down to the locale(s) you want to install, then press the SPACE key."
    -		MSG="${MSG}\n\t\tWhen done, press the TAB key to select <Ok>, then press ENTER."
    -		MSG="${MSG}\n\nIt will take a moment for the locale(s) to be installed."
    -		MSG="${MSG}\n\nWhen that is completed, rerun the Allsky installation."
    +		MSG+="\nYou need to install and set one before Allsky installation can run."
    +		MSG+="\nTo install locales, run:"
    +		MSG+="\n\tsudo raspi-config"
    +		MSG+="\n\t\tPick 'Localisation Options'"
    +		MSG+="\n\t\tPick 'Locale'"
    +		MSG+="\n\t\tScroll down to the locale(s) you want to install, then press the SPACE key."
    +		MSG+="\n\t\tWhen done, press the TAB key to select <Ok>, then press ENTER."
    +		MSG+="\n\nIt will take a moment for the locale(s) to be installed."
    +		MSG+="\n\nWhen that is completed, rerun the Allsky installation."
     		display_msg --log error "${MSG}"
     
     		exit_installation 1 "${STATUS_NO_LOCALE}" "None on system."
    @@ -1199,11 +1105,11 @@ get_desired_locale()
     	# let the user know.
     	# This can happen if they use the settings file from a different Pi or different OS.
     	local MSG2=""
    -	if [[ -z ${DESIRED_LOCALE} && -n ${PRIOR_ALLSKY} && -n ${PRIOR_SETTINGS_FILE} ]]; then
    +	if [[ -z ${DESIRED_LOCALE} && ${USE_PRIOR_ALLSKY} == "true" && -n ${PRIOR_SETTINGS_FILE} ]]; then
     		# People rarely change locale once set, so assume they still want the prior one.
    -		DESIRED_LOCALE="$( settings .locale "${PRIOR_SETTINGS_FILE}" )"
    +		DESIRED_LOCALE="$( settings ".locale" "${PRIOR_SETTINGS_FILE}" )"
     		if [[ -n ${DESIRED_LOCALE} ]]; then
    -			local X="$(echo "${INSTALLED_LOCALES}" | grep "${DESIRED_LOCALE}")"
    +			local X="$( echo "${INSTALLED_LOCALES}" | grep "${DESIRED_LOCALE}" )"
     			if [[ -z ${X} ]]; then
     				# This is probably EXTREMELY rare.
     				MSG2="NOTE: Your prior locale (${DESIRED_LOCALE}) is no longer installed on this Pi."
    @@ -1213,12 +1119,12 @@ get_desired_locale()
     
     	# Get current locale to use as the default.
     	# Ignore any line that doesn't have a value, and get rid of double quotes.
    -	local TEMP_LOCALE="$(locale | grep -E "^LANG=|^LANGUAGE=|^LC_ALL=" | sed -e '/=$/d' -e 's/"//g')"
    -	CURRENT_LOCALE="$(echo "${TEMP_LOCALE}" | sed --silent -e '/LANG=/ s/LANG=//p')"
    +	local TEMP_LOCALE="$( locale | grep -E "^LANG=|^LANGUAGE=|^LC_ALL=" | sed -e '/=$/d' -e 's/"//g' )"
    +	CURRENT_LOCALE="$( echo "${TEMP_LOCALE}" | sed --silent -e '/LANG=/ s/LANG=//p' )"
     	if [[ -z ${CURRENT_LOCALE} ]];  then
    -		CURRENT_LOCALE="$(echo "${TEMP_LOCALE}" | sed --silent -e '/LANGUAGE=/ s/LANGUAGE=//p')"
    +		CURRENT_LOCALE="$( echo "${TEMP_LOCALE}" | sed --silent -e '/LANGUAGE=/ s/LANGUAGE=//p' )"
     		if [[ -z ${CURRENT_LOCALE} ]];  then
    -			CURRENT_LOCALE="$(echo "${TEMP_LOCALE}" | sed --silent -e '/LC_ALL=/ s/LC_ALL=//p')"
    +			CURRENT_LOCALE="$( echo "${TEMP_LOCALE}" | sed --silent -e '/LC_ALL=/ s/LC_ALL=//p' )"
     		fi
     	fi
     	MSG="CURRENT_LOCALE=${CURRENT_LOCALE}, TEMP_LOCALE=[[$( echo "${TEMP_LOCALE}" | tr '\n' ' ' )]]"
    @@ -1234,16 +1140,16 @@ get_desired_locale()
     
     	# If they had a locale from the prior Allsky and it's still here, use it; no need to prompt.
     	if [[ -n ${DESIRED_LOCALE} && ${DESIRED_LOCALE} == "${CURRENT_LOCALE}" ]]; then
    -		STATUS_VARIABLES+=("get_desired_locale='true'\n")
     		STATUS_VARIABLES+=("DESIRED_LOCALE='${DESIRED_LOCALE}'\n")
    +		STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
     		return
     	fi
     
     	MSG="\nSelect your locale; the default is highlighted in red."
    -	MSG="${MSG}\nIf your desired locale is not in the list, press <Cancel>."
    -	MSG="${MSG}\n\nIf you change the locale, the system will reboot and"
    -	MSG="${MSG}\nyou will need to continue the installation."
    -	[[ -n ${MSG2} ]] && MSG="${MSG}\n\n${MSG2}"
    +	MSG+="\nIf your desired locale is not in the list, press <Cancel>."
    +	MSG+="\n\nIf you change the locale, the system will reboot and"
    +	MSG+="\nyou will need to continue the installation."
    +	[[ -n ${MSG2} ]] && MSG+="\n\n${MSG2}"
     
     	# This puts in IL the necessary strings to have whiptail display what looks like
     	# a single column of selections.  Could also use "--noitem" if we passed in a non-null
    @@ -1251,16 +1157,16 @@ get_desired_locale()
     	local IL=()
     	for i in ${INSTALLED_LOCALES}
     	do
    -		IL+=("$i" "")
    +		IL+=("${i}" "")
     	done
     
     	#shellcheck disable=SC2086
    -	DESIRED_LOCALE=$(whiptail --title "${TITLE}" ${D} --menu "${MSG}" 25 "${WT_WIDTH}" 4 "${IL[@]}" \
    -		3>&1 1>&2 2>&3)
    +	DESIRED_LOCALE=$( whiptail --title "${TITLE}" ${D} --menu "${MSG}" 25 "${WT_WIDTH}" 4 "${IL[@]}" \
    +		3>&1 1>&2 2>&3 )
     	if [[ -z ${DESIRED_LOCALE} ]]; then
     		MSG="You need to set the locale before the installation can run."
    -		MSG="${MSG}\n  If your desired locale was not in the list,"
    -		MSG="${MSG}\n   run 'raspi-config' to update the list, then rerun the installation."
    +		MSG+="\n  If your desired locale was not in the list,"
    +		MSG+="\n   run 'sudo raspi-config' to update the list, then rerun the installation."
     		display_msg info "${MSG}"
     		display_msg --logonly info "No locale selected; exiting."
     
    @@ -1271,13 +1177,13 @@ get_desired_locale()
     		# Must be no space between the last double quote and ${INSTALLED_LOCALES}.
     		#shellcheck disable=SC2086
     		MSG="Got usage message from whiptail: D='${D}', INSTALLED_LOCALES="${INSTALLED_LOCALES}
    -		MSG="${MSG}\n  Fix the problem and try the installation again."
    +		MSG+="\n  Fix the problem and try the installation again."
     		display_msg --log error "${MSG}"
     		exit_installation 1 "${STATUS_ERROR}" "Got usage message from whitail."
     	fi
     
    -	STATUS_VARIABLES+=("get_desired_locale='true'\n")
     	STATUS_VARIABLES+=("DESIRED_LOCALE='${DESIRED_LOCALE}'\n")
    +	STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
     }
     
     
    @@ -1285,35 +1191,37 @@ get_desired_locale()
     # Set the locale
     set_locale()
     {
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
    +
     	# ${DESIRED_LOCALE} and ${CURRENT_LOCALE} are already set
     
     	if [[ ${CURRENT_LOCALE} == "${DESIRED_LOCALE}" ]]; then
     		display_msg --log progress "Keeping '${DESIRED_LOCALE}' locale."
    -		local L="$( settings .locale )"
    +		local L="$( settings ".locale" )"
     		MSG="Settings file '${SETTINGS_FILE}'"
     		if [[ -z ${L} ]]; then
     			# Either a new install or an upgrade from an older Allsky.
    -			MSG="${MSG} did NOT contain .locale so adding it."
    +			MSG+=" did NOT contain .locale so adding it."
     			display_msg --logonly info "${MSG}"
    -			update_json_file ".locale" "${DESIRED_LOCALE}"  "${SETTINGS_FILE}"
    +			doV "" "DESIRED_LOCALE" "locale" "text" "${SETTINGS_FILE}"
     
    -# TODO: Something appears to still be unlinking the settings file
    -# from its camera-specific file, so do "ls" of the settings
    -# files to try and pinpoint the problem.
    +# TODO: Something was unlinking the settings file from its camera-specific file,
    +# so do "ls" of the settings files to try and pinpoint the problem.
    +# I think this was fixed in v2023.05.01_03...
     #shellcheck disable=SC2012
     MSG="$( /bin/ls -l "${ALLSKY_CONFIG}/settings"*.json 2>/dev/null | sed 's/^/    /' )"
     display_msg --logonly info "Settings files now:\n${MSG}"
     
     		else
    -			MSG="${MSG} CONTAINED .locale = '${L}'."
    +			MSG+=" CONTAINED .locale = '${L}'."
     			display_msg --logonly info "${MSG}"
     		fi
    -		STATUS_VARIABLES+=("set_locale='true'\n")
    +		STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
     		return
     	fi
     
     	display_msg --log progress "Setting locale to '${DESIRED_LOCALE}'."
    -	update_json_file ".locale" "${DESIRED_LOCALE}"  "${SETTINGS_FILE}"
    +	doV "" "DESIRED_LOCALE" "locale" "text" "${SETTINGS_FILE}"
     
     # TODO: same as above...
     #shellcheck disable=SC2012
    @@ -1328,7 +1236,7 @@ display_msg --logonly info "Settings files now:\n${MSG}"
     		do_reboot "${STATUS_LOCALE_REBOOT}" ""		# does not return
     	fi
     
    -	display_msg warning "You must reboot before continuing with the installation."
    +	display_msg warning "You must reboot to set the locale before continuing with the installation."
     	display_msg --logonly info "User elected not to reboot to update locale."
     
     	exit_installation 0 "${STATUS_NO_REBOOT}" "to update locale."
    @@ -1339,90 +1247,240 @@ display_msg --logonly info "Settings files now:\n${MSG}"
     # See what steps, if any, can be skipped.
     set_what_can_be_skipped()
     {
    -	if [[ ${PRIOR_ALLSKY} != "" ]]; then
    +	if [[ ${USE_PRIOR_ALLSKY} == "true" ]]; then
     		local OLD_VERSION="${1}"
    -		local OLD_BASE_VERSION="${OLD_VERSION:0:11}"	# Without point release
     		local NEW_VERSION="${2}"
    -		if [[ ${NEW_VERSION} == "v2023.05.01_02" && ${OLD_BASE_VERSION} == "v2023.05.01" ]]; then
    +
    +		if [[ ${NEW_VERSION} == "${OLD_VERSION}" ]]; then
     			# No changes to these packages so no need to reinstall.
    -			MSG="Skipping installation of: webserver et.al., PHP modules, Trutype fonts, Python"
    +			MSG="Skipping installation of: webserver et.al., PHP modules, Truetype fonts, Python"
     			display_msg --logonly info "${MSG}"
    +			# shellcheck disable=SC2034
     			install_webserver_et_al="true"
    -			installed_PHP_modules="true"
    -			installing_Trutype_fonts="true"
    -		  	installed_Python_dependencies="true"
    +			# shellcheck disable=SC2034
    +			install_fonts="true"
    +			# shellcheck disable=SC2034
    +			install_PHP_modules="true"
    +			# need to always run install_Python() so it can set up venv
     		fi
     	fi
     }
     
     ####
     # Do we need to reboot?
    +# Use the prior version's info, even if we won't use its settings.
     is_reboot_needed()
     {
     	local OLD_VERSION="${1}"
    -	local OLD_BASE_VERSION="${OLD_VERSION:0:11}"	# Without point release
    +	local OLD_BASE_VERSION="$( remove_point_release "${OLD_VERSION}" )"
     	local NEW_VERSION="${2}"
    -	if [[ ${OLD_VERSION} < "v2023.05.01_04" ]]; then
    -		# v2023.05.01_04 added to $PATH and a reboot's needed to have it take effect.
    -		display_msg --log progress "A reboot is needed after installation finishes."
    -	else
    +	local NEW_BASE_VERSION="$( remove_point_release "${NEW_VERSION}" )"
    +
    +	if [[ ${NEW_BASE_VERSION} == "${OLD_BASE_VERSION}" ]]; then
    +		# Assume just bug fixes between point releases.
    +# TODO: this may not always be true.
     		REBOOT_NEEDED="false"
     		display_msg --logonly info "No reboot is needed."
    +	else
    +		REBOOT_NEEDED="true"
    +		display_msg --log progress "A reboot is needed after installation finishes."
    +	fi
    +}
    +
    +NEW_STYLE_ALLSKY="newStyle"
    +OLD_STYLE_ALLSKY="oldStyle"
    +
    +####
    +# See if a prior Allsky Website exists; if so, set some variables.
    +# First look in the prior Allsky directory, if it exists.
    +# If not, look in the old Website location.
    +PRIOR_WEBSITE_STYLE=""
    +PRIOR_WEBSITE_DIR=""
    +PRIOR_WEBSITE_CONFIG_FILE=""
    +
    +# Versions of the Website configuration files: 1, 2, etc.
    +NEW_WEB_CONFIG_VERSION=""
    +PRIOR_WEB_CONFIG_VERSION=""
    +
    +# Run every time in case the Website was removed after first run.
    +does_prior_Allsky_Website_exist()
    +{
    +	local PRIOR_STYLE="${1}"
    +
    +	if [[ ${PRIOR_STYLE} == "${NEW_STYLE_ALLSKY}" ]]; then
    +		PRIOR_WEBSITE_DIR="${PRIOR_ALLSKY_DIR}${ALLSKY_WEBSITE/${ALLSKY_HOME}/}"
    +		if [[ -d ${PRIOR_WEBSITE_DIR} ]]; then
    +			PRIOR_WEBSITE_STYLE="${NEW_STYLE_ALLSKY}"
    +
    +			PRIOR_WEBSITE_CONFIG_FILE="${PRIOR_WEBSITE_DIR}/${ALLSKY_WEBSITE_CONFIGURATION_NAME}"
    +			PRIOR_WEB_CONFIG_VERSION="$( settings ".${WEBSITE_CONFIG_VERSION}" "${PRIOR_WEBSITE_CONFIG_FILE}" )"
    +			if [[ -z ${PRIOR_WEB_CONFIG_VERSION} ]]; then
    +				# This shouldn't happen ...
    +				MSG="Missing ${WEBSITE_CONFIG_VERSION} in ${PRIOR_WEBSITE_CONFIG_FILE}."
    +				MSG+="\nYou need to manually copy your prior local Allsky Website settings to"
    +				MSG+="\n${ALLSKY_WEBSITE_CONFIGURATION_FILE}."
    +				display_msg --log error "${MSG}"
    +				PRIOR_WEB_CONFIG_VERSION="1"		# Assume the oldest version
    +			fi
    +		else
    +			PRIOR_WEBSITE_DIR=""
    +		fi
    +	else
    +		# Either old style, or didn't find a prior Allsky.
    +		# Either way, look in the old location.
    +		PRIOR_WEBSITE_DIR="${PRIOR_WEBSITE_LOCATION}"
    +		if [[ -d ${PRIOR_WEBSITE_DIR} ]]; then
    +			PRIOR_WEBSITE_STYLE="${OLD_STYLE_ALLSKY}"
    +			# old style websites don't have ${WEBSITE_CONFIG_VERSION}.
    +		else
    +			PRIOR_WEBSITE_DIR=""
    +		fi
    +	fi
    +
    +	if [[ -z ${PRIOR_WEBSITE_DIR} ]]; then
    +		display_msg --logonly info "No prior Allsky Website found."
    +	else
    +		display_msg --logonly info "PRIOR_WEBSITE_STYLE=${PRIOR_WEBSITE_STYLE}"
    +		display_msg --logonly info "PRIOR_WEBSITE_DIR=${PRIOR_WEBSITE_DIR}"
    +		# New Website configuration file may not exist yet so use repo version.
    +		NEW_WEB_CONFIG_VERSION="$( settings ".${WEBSITE_CONFIG_VERSION}" "${REPO_WEBCONFIG_FILE}" )"
    +		display_msg --logonly info "NEW_WEB_CONFIG_VERSION=${NEW_WEB_CONFIG_VERSION}"
     	fi
     }
     
     ####
     # See if a prior Allsky exists; if so, set some variables.
    +
     does_prior_Allsky_exist()
     {
    -	PRIOR_ALLSKY=""
    -	PRIOR_CAMERA_TYPE=""
    -	PRIOR_CAMERA_MODEL=""
    +	local MSG  DIR  CAPTURE  STRING
    +
    +	# ${PRIOR_ALLSKY_DIR} points to where the prior Allsky would be.
    +	# Make sure it's there and is valid.
     
    -	# Don't just look for the top-level directory.
    +	MSG="Prior Allsky directory found at '${PRIOR_ALLSKY_DIR}'"
    +	# If a prior config directory doesn't exist then there's no prior Allsky.
     	if [[ ! -d ${PRIOR_CONFIG_DIR} ]]; then
    -		display_msg --logonly info "No prior Allsky found."
    +		if [[ -d ${PRIOR_ALLSKY_DIR} ]]; then
    +			MSG+=" but it doesn't appear to have been installed; ignoring it."
    +			display_msg --log warning "${MSG}"
    +		else
    +			display_msg --logonly info "No prior Allsky found at '${PRIOR_ALLSKY_DIR}'."
    +		fi
    +		does_prior_Allsky_Website_exist ""
    +		USE_PRIOR_ALLSKY="false"
     		return 1
     	fi
     
    -	PRIOR_ALLSKY_VERSION="$( get_version "${PRIOR_ALLSKY_DIR}/" )"
    -	if [[ -n  ${PRIOR_ALLSKY_VERSION} ]]; then
    -		display_msg --logonly info "Prior Allsky version ${PRIOR_ALLSKY_VERSION} found."
    -		if [[ ${PRIOR_ALLSKY_VERSION} == "v2022.03.01" ]]; then
    -			# First Allsky version with a "version" file.
    -			# This is an old style Allsky with ${CAMERA} in config.sh.
    -			# Don't do anything here; go to the "if" below.
    -			:
    -		else
    -			# Newer version.
    -			# PRIOR_SETTINGS_FILE should be a link to a camera-specific settings file.
    -			PRIOR_ALLSKY="newStyle"
    -			PRIOR_SETTINGS_FILE="${PRIOR_CONFIG_DIR}/${SETTINGS_FILE_NAME}"
    -			if [[ -f ${PRIOR_SETTINGS_FILE} ]]; then
    +	# All versions back to v0.6 (never checked prior ones) have a "scripts" directory.
    +	if [[ ! -d "${PRIOR_ALLSKY_DIR}/scripts" ]]; then
    +		MSG+=" but it doesn't appear to be valid or it too old; ignoring it."
    +		display_msg --log warning "${MSG}"
    +		does_prior_Allsky_Website_exist ""
    +		USE_PRIOR_ALLSKY="false"
    +		return 1
    +	fi
    +
    +	display_msg --logonly info "Prior Allsky found at '${PRIOR_ALLSKY_DIR}'."
    +	USE_PRIOR_ALLSKY="true"		# may be set to false after user is prompted to use it
    +
    +	# Determine the prior Allsky version and set some PRIOR_* locations.
    +	PRIOR_ALLSKY_VERSION="$( get_version "${PRIOR_ALLSKY_DIR}/" )"	# Returns "" if no version file.
    +	if [[ -n ${PRIOR_ALLSKY_VERSION} && (! ${PRIOR_ALLSKY_VERSION} < "${FIRST_CAMERA_TYPE_BASE_VERSION}") ]]; then
    +		PRIOR_ALLSKY_STYLE="${NEW_STYLE_ALLSKY}"
    +		if [[ ${RESTORE} == "true" ]]; then
    +			does_prior_Allsky_Website_exist "${PRIOR_ALLSKY_STYLE}"
    +			return 0
    +		fi
    +
    +		# PRIOR_SETTINGS_FILE should be a link to a camera-specific settings file
    +		# and that file will have the camera type and model.
    +		PRIOR_SETTINGS_FILE="${PRIOR_CONFIG_DIR}/${SETTINGS_FILE_NAME}"
    +		if [[ -f ${PRIOR_SETTINGS_FILE} ]]; then
    +			# Look for newer, lowercase setting names
    +			PRIOR_CAMERA_TYPE="$( settings ".cameratype" "${PRIOR_SETTINGS_FILE}" )"
    +			if [[ -n ${PRIOR_CAMERA_TYPE} ]]; then
    +				PRIOR_CAMERA_MODEL="$( settings ".cameramodel" "${PRIOR_SETTINGS_FILE}" )"
    +				PRIOR_CAMERA_NUMBER="$( settings ".cameranumber" "${PRIOR_SETTINGS_FILE}" )"
    +			else
     				PRIOR_CAMERA_TYPE="$( settings ".cameraType" "${PRIOR_SETTINGS_FILE}" )"
     				PRIOR_CAMERA_MODEL="$( settings ".cameraModel" "${PRIOR_SETTINGS_FILE}" )"
    -			else
    -				# This shouldn't happen...
    -				PRIOR_SETTINGS_FILE=""
    -				display_msg --log warning "No prior new style settings file found!"
    +				PRIOR_CAMERA_NUMBER="$( settings ".cameraNumber" "${PRIOR_SETTINGS_FILE}" )"
     			fi
    +		else
    +			# This shouldn't happen...
    +			PRIOR_SETTINGS_FILE=""
    +			MSG="No prior new style settings file (${PRIOR_SETTINGS_FILE}) found!"
    +			display_msg --log warning "${MSG}"
     		fi
    -	fi
     
    -	if [[ -z ${PRIOR_ALLSKY} ]]; then
    -		PRIOR_ALLSKY="oldStyle"
    -		PRIOR_ALLSKY_VERSION="${PRIOR_ALLSKY_VERSION:-old}"
    +	else		# pre-${FIRST_VERSION_VERSION}
    +		# V0.6, v0.7, and v0.8:
    +		#	"allsky" directory contained capture.cpp, config.sh.
    +		#	"scripts" directory had ftp-settings.sh.
    +		#	No "src" directory.
    +			# NOTE: v0.6's capture.cpp said v0.5.
    +		# V0.8.1 added "scr" and "config" directories and "variables.sh" file.
    +
     		local CAMERA="$( get_variable "CAMERA" "${PRIOR_CONFIG_FILE}" )"
     		PRIOR_CAMERA_TYPE="$( CAMERA_to_CAMERA_TYPE "${CAMERA}" )"
    +
    +		PRIOR_ALLSKY_STYLE="${OLD_STYLE_ALLSKY}"
    +		if [[ ${RESTORE} == "true" ]]; then
    +			does_prior_Allsky_Website_exist "${PRIOR_ALLSKY_STYLE}"
    +			return 0
    +		fi
    +
    +		if [[ -z ${PRIOR_ALLSKY_VERSION} ]]; then
    +			# No version file so try to determine version via .cpp file.
    +			# sample:    printf("%s *** Allsky Camera Software v0.8.3 | 2021 ***\n", c(KGRN));
    +			DIR="${PRIOR_ALLSKY_DIR}/src"
    +			if [[ ! -d "${DIR}" ]]; then
    +				# Really old versions had source in the top directory.
    +				DIR="${PRIOR_ALLSKY_DIR}"
    +			fi
    +			CAPTURE="${DIR}/capture_${PRIOR_CAMERA_TYPE}.cpp"
    +			if [[ ! -f ${CAPTURE} ]]; then
    +				MSG="${CAPTURE} not found; "
    +				CAPTURE="${DIR}/capture.cpp"	# old name for ZWO
    +				MSG+=" using ${CAPTURE} instead"
    +				display_msg --logonly "info" "${MSG}"
    +			fi
    +
    +			MSG2="\nWill NOT use your prior Allsky;"
    +			MSG2+=" you will need to copy files and setting manually."
    +			if [[ ! -f ${CAPTURE} ]]; then
    +				MSG="Cannot find prior 'capture*.cpp' program in '${DIR}'".
    +				display_msg --log "warning" "${MSG}${MSG2}"
    +				USE_PRIOR_ALLSKY="false"
    +				return 1
    +			fi
    +			STRING="Camera Software"
    +			if ! PRIOR_ALLSKY_VERSION="$( grep "Camera Software" "${CAPTURE}" |
    +					gawk '{print $6}' )" ; then
    +				MSG="Unable to determine version of prior Allsky: '${STRING}' not in '${CAPTURE}'."
    +				display_msg --log "warning" "${MSG}${MSG2}"
    +				USE_PRIOR_ALLSKY="false"
    +				return 1
    +			fi
    +		fi
    +		PRIOR_ALLSKY_VERSION="${PRIOR_ALLSKY_VERSION:-${PRE_FIRST_VERSION_VERSION}}"
     		# PRIOR_CAMERA_MODEL wasn't stored anywhere so can't set it.
     		PRIOR_SETTINGS_FILE="${OLD_RASPAP_DIR}/settings_${CAMERA}.json"
     		[[ ! -f ${PRIOR_SETTINGS_FILE} ]] && PRIOR_SETTINGS_FILE=""
     	fi
     
    +	if [[ ${PRIOR_ALLSKY_VERSION} != "${PRE_FIRST_VERSION_VERSION}" ]]; then
    +		PRIOR_ALLSKY_BASE_VERSION="$( remove_point_release "${PRIOR_ALLSKY_VERSION}" )"
    +	fi
    +
     	display_msg --logonly info "PRIOR_ALLSKY_VERSION=${PRIOR_ALLSKY_VERSION}"
    -	display_msg --logonly info "PRIOR_CAMERA_TYPE=${PRIOR_CAMERA_TYPE}"
    +	MSG="PRIOR_CAMERA_TYPE=${PRIOR_CAMERA_TYPE}, PRIOR_CAMERA_MODEL=${PRIOR_CAMERA_MODEL:-unknown}"
    +	display_msg --logonly info "${MSG}"
     	display_msg --logonly info "PRIOR_SETTINGS_FILE=${PRIOR_SETTINGS_FILE}"
     
    +	does_prior_Allsky_Website_exist "${PRIOR_ALLSKY_STYLE}"
    +
     	return 0
     }
     
    @@ -1431,349 +1489,671 @@ does_prior_Allsky_exist()
     # If there's a prior version of the software,
     # ask the user if they want to move stuff from there to the new directory.
     # Look for a directory inside the old one to make sure it's really an old allsky.
    +
    +WILL_USE_PRIOR="true"
    +
     prompt_for_prior_Allsky()
     {
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
     
    -	if [[ -n ${PRIOR_ALLSKY} ]]; then
    -		STATUS_VARIABLES+=("prompt_for_prior_Allsky='true'\n")
    -		MSG="You have a prior version of Allsky in ${PRIOR_ALLSKY_DIR}."
    -		MSG="${MSG}\n\nDo you want to restore the prior images, darks, and certain settings?"
    -		if whiptail --title "${TITLE}" --yesno "${MSG}" 15 "${WT_WIDTH}"  3>&1 1>&2 2>&3; then
    -			# Set the prior camera type to the new, default camera type.
    -			CAMERA_TYPE="${PRIOR_CAMERA_TYPE}"
    -			STATUS_VARIABLES+=("CAMERA_TYPE='${CAMERA_TYPE}'\n")
    -			display_msg --logonly info "Will restore from prior version of Allsky."
    -			return 0
    +	if [[ ${WILL_USE_PRIOR} == "true" ]]; then
    +		local MSG
    +
    +		if [[ ${USE_PRIOR_ALLSKY} == "true" ]]; then
    +			MSG="You have a prior version of Allsky in ${PRIOR_ALLSKY_DIR}."
    +			MSG+="\n\nDo you want to restore the prior images and other files you've changed?"
    +			if [[ ${PRIOR_ALLSKY_STYLE} == "${NEW_STYLE_ALLSKY}" ]]; then
    +				MSG+="\nIf so, your prior settings will be restored as well."
    +			else
    +				MSG+="\nIf so, we will attempt to use its settings as well, but may not be"
    +				MSG+="\nable to use ALL prior settings depending on how old your prior Allsky is."
    +				MSG+="\nIn that case, you'll be prompted for required information such as"
    +				MSG+="\nthe camera's latitude, logitude, and locale."
    +			fi
    +
    +			if whiptail --title "${TITLE}" --yesno "${MSG}" 20 "${WT_WIDTH}"  3>&1 1>&2 2>&3; then
    +				# Set the prior camera type to the new, default camera type.
    +				CAMERA_TYPE="${PRIOR_CAMERA_TYPE}"
    +				CAMERA_MODEL="${PRIOR_CAMERA_MODEL}"
    +				CAMERA_NUMBER="${PRIOR_CAMERA_NUMBER}"
    +				STATUS_VARIABLES+=("CAMERA_TYPE='${CAMERA_TYPE}'\n")
    +				STATUS_VARIABLES+=("CAMERA_MODEL='${CAMERA_MODEL}'\n")
    +				STATUS_VARIABLES+=("CAMERA_NUMBER='${CAMERA_NUMBER}'\n")
    +				display_msg --logonly info "Will restore from prior version of Allsky."
    +			else
    +				USE_PRIOR_ALLSKY="false"
    +				PRIOR_SETTINGS_FILE=""
    +				CAMERA_TYPE=""
    +				PRIOR_CAMERA_TYPE=""
    +				PRIOR_CAMERA_MODEL=""
    +				PRIOR_CAMERA_NUMBER=""
    +				display_msg --logonly info "Will NOT restore from prior version of Allsky."
    +				MSG="If you want your old images, darks, settings, etc. from the prior version"
    +				MSG+=" of Allsky, you'll need to manually move them to the new version."
    +				display_msg info "${MSG}"
    +				WILL_USE_PRIOR="false"
    +			fi
     		else
    -			PRIOR_ALLSKY_DIR=""
    -			PRIOR_ALLSKY=""
    -			PRIOR_ALLSKY_VERSION=""
    -			CAMERA_TYPE=""
    -			PRIOR_CAMERA_TYPE=""
    -			MSG="If you want your old images, darks, settings, etc. from the prior version"
    -			MSG="${MSG} of Allsky, you'll need to manually move them to the new version."
    -			whiptail --title "${TITLE}" --msgbox "${MSG}" 12 "${WT_WIDTH}" 3>&1 1>&2 2>&3
    -			display_msg --logonly info "Will NOT restore from prior version of Allsky."
    -		fi
    -	else
    -		MSG="No prior version of Allsky found."
    -		MSG="${MSG}\n\nIf you DO have a prior version and you want images, darks, and certain settings moved from the prior version to the new one, rename the prior version to ${PRIOR_ALLSKY_DIR} before running this installation."
    -		MSG="${MSG}\n\nDo you want to continue?"
    -		if ! whiptail --title "${TITLE}" --yesno "${MSG}" 15 "${WT_WIDTH}" 3>&1 1>&2 2>&3; then
    -			MSG="Rename the directory with your prior version of Allsky to"
    -			MSG="${MSG}\n '${PRIOR_ALLSKY_DIR}', then run the installation again."
    -			display_msg info "${MSG}"
    -			display_msg --logonly info "User elected not to continue.  Exiting installation."
    -			exit_installation 0 "${STATUS_NOT_CONTINUE}" "after no prior Allsky was found."
    +			MSG="No prior version of Allsky found."
    +			MSG+="\n\nIf you DO have a prior version and you want images, darks,"
    +			MSG+=" and certain settings moved from the prior version to the new one,"
    +			MSG+=" rename the prior version to ${PRIOR_ALLSKY_DIR} before running this installation."
    +			MSG+="\n\nDo you want to continue?"
    +			if ! whiptail --title "${TITLE}" --yesno "${MSG}" 15 "${WT_WIDTH}" 3>&1 1>&2 2>&3; then
    +				MSG="Rename the directory with your prior version of Allsky to"
    +				MSG+="\n '${PRIOR_ALLSKY_DIR}', then run the installation again."
    +				display_msg info "${MSG}"
    +				display_msg --logonly info "User elected not to continue.  Exiting installation."
    +				exit_installation 0 "${STATUS_NOT_CONTINUE}" "after no prior Allsky was found."
    +			fi
    +			WILL_USE_PRIOR="false"
     		fi
    -		STATUS_VARIABLES+=("prompt_for_prior_Allsky='true'\n")
     	fi
     
    -	# No prior Allsky so force creating a default settings file.
    -	FORCE_CREATING_DEFAULT_SETTINGS_FILE="true"
    -	STATUS_VARIABLES+=("FORCE_CREATING_DEFAULT_SETTINGS_FILE='${FORCE_CREATING_DEFAULT_SETTINGS_FILE}'\n")
    +	if [[ ${WILL_USE_PRIOR} == "false" ]]; then
    +		# No prior Allsky (or the user doesn't want to use it),
    +		# so force creating a default settings file.
    +		FORCE_CREATING_DEFAULT_SETTINGS_FILE="true"
    +		STATUS_VARIABLES+=("FORCE_CREATING_DEFAULT_SETTINGS_FILE='true'\n")
    +		STATUS_VARIABLES+=("USE_PRIOR_ALLSKY='${USE_PRIOR_ALLSKY}'\n")
    +	fi
    +
    +	STATUS_VARIABLES+=("WILL_USE_PRIOR='${WILL_USE_PRIOR}'\n")
    +	STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
     }
     
     
     ####
     install_dependencies_etc()
     {
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
    +	[[ ${SKIP} == "true" ]] && return
    +
     	# These commands produce a TON of output that's not needed unless there's a problem.
     	# They also take a little while, so hide the output and let the user know.
     
     	display_msg --log progress "Installing dependencies."
    -	TMP="${ALLSKY_INSTALLATION_LOGS}/make_deps.log"
    +
    +	local T="${ALLSKY_SCRIPTS}/allsky-config"
    +	if [[ ! -f "${T}" ]]; then
    +		local F="${ALLSKY_UTILITIES}/allsky-config.sh"
    +		display_msg --logonly info "Creating link to ${F}"
    +		ln -s "${F}" "${T}"		|| echo "Unable to ln -s '${F}' '${T}'" >&2
    +	fi
    +
    +	local T="${ALLSKY_SCRIPTS}/functions.php"
    +	if [[ ! -f "${T}" ]]; then
    +		local F="${ALLSKY_WEBUI}/includes/functions.php"
    +		display_msg --logonly info "Creating link to ${F}"
    +		ln -s "${F}" "${T}"		|| echo "Unable to ln -s '${F}' '${T}'" >&2
    +	fi
    +
    +	TMP="${ALLSKY_LOGS}/make_deps.log"
     	#shellcheck disable=SC2024
     	sudo make deps > "${TMP}" 2>&1
    -	check_success $? "Dependency installation failed" "${TMP}" "${DEBUG}"
    -	[[ $? -ne 0 ]] && exit_with_image 1 "${STATUS_ERROR}" "dependency installation failed"
    +	check_success $? "Dependency installation failed" "${TMP}" "${DEBUG}" ||
    +		exit_with_image 1 "${STATUS_ERROR}" "dependency installation failed"
    +
    +	# Set some default locations needed by the capture programs so we
    +	# don't need to pass them in on the command line - if they are passed in,
    +	# those values overwrite the defaults.
    +	sed \
    +		-e "s;XX_ALLSKY_HOME_XX;${ALLSKY_HOME};" \
    +		-e "s;XX_CONNECTED_CAMERAS_FILE_XX;${CONNECTED_CAMERAS_INFO};" \
    +		-e "s;XX_RPI_CAMERA_INFO_FILE_XX;${RPi_SUPPORTED_CAMERAS};" \
    +		"${ALLSKY_HOME}/src/include/allsky_common.h.repo" > "${ALLSKY_HOME}/src/include/allsky_common.h"
     
     	display_msg --log progress "Preparing Allsky commands."
    -	TMP="${ALLSKY_INSTALLATION_LOGS}/make_all.log"
    +	TMP="${ALLSKY_LOGS}/make_all.log"
     	#shellcheck disable=SC2024
     	make all > "${TMP}" 2>&1
    -	check_success $? "Compile failed" "${TMP}" "${DEBUG}"
    -	[[ $? -ne 0 ]] && exit_with_image 1 "${STATUS_ERROR}" "compile failed"
    +	check_success $? "Compile failed" "${TMP}" "${DEBUG}" ||
    +		exit_with_image 1 "${STATUS_ERROR}" "compile failed"
     
    -	TMP="${ALLSKY_INSTALLATION_LOGS}/make_install.log"
    +	TMP="${ALLSKY_LOGS}/make_install.log"
     	#shellcheck disable=SC2024
     	sudo make install > "${TMP}" 2>&1
    -	check_success $? "make install failed" "${TMP}" "${DEBUG}"
    -	[[ $? -ne 0 ]] && exit_with_image 1 "${STATUS_ERROR}" "make insall_failed"
    -
    -	STATUS_VARIABLES+=("install_dependencies_etc='true'\n")
    -	return 0
    -}
    -
    -
    -####
    -# Update config.sh
    -update_config_sh()
    -{
    -	local C="${ALLSKY_CONFIG}/config.sh"
    -	display_msg --log progress "Updating some '${C}' variables."
    -	if [[ -z ${CAMERA_TYPE} ]]; then
    -		display_msg --log error "CAMERA_TYPE is empty in update_config_sh()"
    -		CAMERA_TYPE="$( settings .cameraType )"
    -	fi
    -	sed -i \
    -		-e "s;XX_ALLSKY_VERSION_XX;${ALLSKY_VERSION};" \
    -		-e "s;^CAMERA_TYPE=.*$;CAMERA_TYPE=\"${CAMERA_TYPE}\";" \
    -		"${C}"
    +	check_success $? "make install failed" "${TMP}" "${DEBUG}" ||
    +		exit_with_image 1 "${STATUS_ERROR}" "make insall_failed"
     
    -	STATUS_VARIABLES+=( "update_config_sh='true'\n" )
    +	STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
     }
     
     
     ####
     # Create the log file and make it readable/writable by the user; this aids in debugging.
    +# Re-run every time in case permissions changed.
     create_allsky_logs()
     {
    +	local DO_ALL="${1}"
    +
     	display_msg --log progress "Setting permissions on ${ALLSKY_LOG} and ${ALLSKY_PERIODIC_LOG}."
     
    -	sudo systemctl stop rsyslog 2> /dev/null
    +	if [[ ${DO_ALL} == "true" ]]; then
    +		sudo systemctl stop rsyslog 2> /dev/null
     
    -	TMP="${ALLSKY_INSTALLATION_LOGS}/rsyslog.log"
    -	sudo apt-get --assume-yes install rsyslog > "${TMP}" 2>&1	
    -	check_success $? "rsyslog installation failed" "${TMP}" "${DEBUG}"
    -	[[ $? -ne 0 ]] && exit_with_image 1 "${STATUS_ERROR}" "rsyslog install failed."
    +		TMP="${ALLSKY_LOGS}/rsyslog.log"
    +		run_aptGet rsyslog > "${TMP}" 2>&1
    +		check_success $? "rsyslog installation failed" "${TMP}" "${DEBUG}" ||
    +			exit_with_image 1 "${STATUS_ERROR}" "rsyslog install failed."
    +	fi
     
     	sudo truncate -s 0 "${ALLSKY_LOG}" "${ALLSKY_PERIODIC_LOG}"
     	sudo chmod 664 "${ALLSKY_LOG}" "${ALLSKY_PERIODIC_LOG}"
     	sudo chgrp "${ALLSKY_GROUP}" "${ALLSKY_LOG}" "${ALLSKY_PERIODIC_LOG}"
     
    -	sudo systemctl start rsyslog		# so logs go to the files above
    +	if [[ ${DO_ALL} == "true" ]]; then
    +		sudo systemctl start rsyslog		# so logs go to the files above
    +	fi
     }
     
     
     ####
    -# Prompt for either latitude or longitude, and make sure it's a valid entry.
    -prompt_for_lat_long()
    +# If needed, update the new settings file based on the prior one.
    +# The old and new files both exist and may be the same,
    +# but either way, do not modify the old file.
    +
    +# Can't use variables since these messages are displayed in a "while read x" loop which
    +# runs in a subshell.
    +DISPLAYED_BRIGHTNESS_MSG="/tmp/displayed_brightness_msg"
    +DISPLAYED_OFFSET_MSG="/tmp/displayed_offset_msg"
    +rm -f "${DISPLAYED_BRIGHTNESS_MSG}" "${DISPLAYED_OFFSET_MSG}"
    +
    +convert_settings()			# prior_file, new_file
     {
    -	local PROMPT="${1}"
    -	local TYPE="${2}"
    -	local HUMAN_TYPE="${3}"
    -	local ERROR_MSG=""
    -	local VALUE=""
    +	local PRIOR_FILE="${1}"
    +	local NEW_FILE="${2}"
    +	local CALLED_FROM="${3}"
     
    -	while :
    -	do
    -		local M="${ERROR_MSG}${PROMPT}"
    -		VALUE=$(whiptail --title "${TITLE}" --inputbox "${M}" 18 "${WT_WIDTH}" "" 3>&1 1>&2 2>&3)
    -		if [[ -z ${VALUE} ]]; then
    -			# Let the user not enter anything.  A message is printed below.
    -			break
    -		else
    -			if VALUE="$( convertLatLong "${VALUE}" "${TYPE}" 2>&1 )" ; then
    -				update_json_file ".${TYPE}" "${VALUE}" "${SETTINGS_FILE}"
    -				display_msg --log progress "${HUMAN_TYPE} set to ${VALUE}."
    -				echo "${VALUE}"
    -				break
    -			else
    -				ERROR_MSG="${VALUE}\n\n"
    -			fi
    -		fi
    -	done
    +	if [[ ${ALLSKY_VERSION} == "${PRIOR_ALLSKY_VERSION}" ]]; then
    +		display_msg --logonly info "Not converting '${PRIOR_FILE}'; same ALLSKY_VERSION."
    +		return
    +	fi
    +
    +	# If we're upgrading a version >= COMBINED_BASE_VERSION then return.
    +	# bash doesn't have >= so use   ! <
    +	if [[ ! (${PRIOR_ALLSKY_BASE_VERSION} < "${COMBINED_BASE_VERSION}") ]]; then
    +		display_msg --logonly info "Not converting '${PRIOR_FILE}'; >= COMBINED_BASE_VERSION."
    +		return
    +	fi
    +
    +	local MSG="Converting '$( basename "${PRIOR_FILE}" )' to new format:"
    +	display_msg --log progress "${MSG}"
    +
    +	DIR="/tmp/converted_settings"
    +	mkdir -p "${DIR}"
    +	local TEMP_PRIOR="${DIR}/old-${PRIOR_CAMERA_TYPE}_${PRIOR_CAMERA_MODEL}.json"
    +
    +	# Older version had uppercase letters in setting names and "1" and "0" for booleans
    +	# and quotes around numbers. Change that.
    +	# Don't modify the prior file, so make the changes to a temporary file.
    +	# --settings-only  says only output settings that are in the settings file.
    +	# The OPTIONS_FILE doesn't exist yet so use REPO_OPTIONS_FILE>
    +	"${ALLSKY_SCRIPTS}/convertJSON.php" \
    +		--convert \
    +		--settings-only \
    +		--settings-file "${PRIOR_FILE}" \
    +		--options-file "${REPO_OPTIONS_FILE}" \
    +		--include-not-in-options \
    +		> "${TEMP_PRIOR}" 2>&1
    +	if [[ $? -ne 0 ]]; then
    +		MSG="Unable to convert old settings file: $( < "${TEMP_PRIOR}" )"
    +		display_msg --log error "${MSG}"
    +		exit_installation 1 "${STATUS_ERROR}" "${MSG}."
    +	fi
    +
    +	# For each field in prior file, update new file with old value.
    +	# Then handle new fields and fields that changed locations or names.
    +
    +	# Output the field name and value as text separated by a tab.
    +	# Field names are already lowercase from above.
    +	"${ALLSKY_SCRIPTS}/convertJSON.php" \
    +			--delimiter "${TAB}" \
    +			--options-file "${REPO_OPTIONS_FILE}" \
    +			--include-not-in-options \
    +			--settings-file "${TEMP_PRIOR}" |
    +		while read -r FIELD VALUE
    +		do
    +			case "${FIELD}" in
    +				"lastchanged")
    +					# Update the value.
    +					VALUE="$( date +'%Y-%m-%d %H:%M:%S' )"
    +					doV "${FIELD}" "VALUE" "${FIELD}" "text" "${NEW_FILE}"
    +					;;
    +
    +				"computer")
    +					# We now compute the value.
    +					VALUE="$( get_computer )"
    +					doV "${FIELD}" "VALUE" "${FIELD}" "text" "${NEW_FILE}"
    +					;;
    +
    +				# Don't carry this forward:
    +				"XX_END_XX")
    +					;;
    +
    +				# These don't exist anymore:
    +				"autofocus" | "background" | "alwaysshowadvanced" | \
    +				"newexposure" | "experimentalexposure" | "showbrightness")
    +					;;
    +
    +				# These two were deleted in ${COMBINED_BASE_VERSION}:
    +				"brightness" | "daybrightness" | "nightbrightness")
    +					if [[ ! -f ${DISPLAYED_BRIGHTNESS_MSG} ]]; then
    +						touch "${DISPLAYED_BRIGHTNESS_MSG}"
    +						MSG="The 'Brightness' settings were removed. Use 'Target Mean' instead."
    +						display_msg --log notice "${MSG}"
    +					fi
    +					;;
    +				"offset")
    +					if [[ ${VALUE} -gt 1 && ! -f ${DISPLAYED_OFFSET_MSG} ]]; then
    +						touch "${DISPLAYED_OFFSET_MSG}"
    +						# 1 is default.  > 1 means they changed it, which is rare.
    +						MSG="The 'Offset' setting was removed."
    +						MSG+="\nUse the 'Target Mean' settings to adjust brightness."
    +						display_msg --log notice "${MSG}"
    +					fi
    +					;;
    +
    +				# These changed names:
    +				"darkframe")
    +					doV "${FIELD}" "VALUE" "takedarkframes" "boolean" "${NEW_FILE}"
    +					;;
    +				"daymaxgain")
    +					doV "${FIELD}" "VALUE" "daymaxautogain" "boolean" "${NEW_FILE}"
    +					;;
    +				"nightmaxexposure")
    +					doV "${FIELD}" "VALUE" "nightmaxautoexposure" "boolean" "${NEW_FILE}"
    +					;;
    +				"nightmaxgain")
    +					doV "${FIELD}" "VALUE" "nightmaxautogain" "boolean" "${NEW_FILE}"
    +					;;
    +				"websiteurl")
    +					doV "${FIELD}" "VALUE" "remotewebsiteurl" "text" "${NEW_FILE}"
    +					;;
    +				"imageurl")
    +					doV "${FIELD}" "VALUE" "remotewebsiteimageurl" "text" "${NEW_FILE}"
    +					;;
    +
    +				# These now have day and night versions:
    +				"awb" | "autowhitebalance")
    +					FIELD="awb"
    +					doV "${FIELD}" "VALUE" "day${FIELD}" "boolean" "${NEW_FILE}"
    +					doV "${FIELD}" "VALUE" "night${FIELD}" "boolean" "${NEW_FILE}"
    +					;;
    +				"wbr")
    +					doV "${FIELD}" "VALUE" "day${FIELD}" "number" "${NEW_FILE}"
    +					doV "${FIELD}" "VALUE" "night${FIELD}" "number" "${NEW_FILE}"
    +					;;
    +				"wbb")
    +					doV "${FIELD}" "VALUE" "day${FIELD}" "number" "${NEW_FILE}"
    +					doV "${FIELD}" "VALUE" "night${FIELD}" "number" "${NEW_FILE}"
    +					;;
    +				"targettemp")
    +					doV "${FIELD}" "VALUE" "day${FIELD}" "number" "${NEW_FILE}"
    +					doV "${FIELD}" "VALUE" "night${FIELD}" "number" "${NEW_FILE}"
    +					;;
    +				"coolerenabled")
    +					FIELD="enablecooler"		# also a name change
    +					doV "${FIELD}" "VALUE" "day${FIELD}" "boolean" "${NEW_FILE}"
    +					doV "${FIELD}" "VALUE" "night${FIELD}" "boolean" "${NEW_FILE}"
    +					;;
    +				"meanthreshold")
    +					doV "${FIELD}" "VALUE" "day${FIELD}" "number" "${NEW_FILE}"
    +					doV "${FIELD}" "VALUE" "night${FIELD}" "number" "${NEW_FILE}"
    +					;;
    +
    +				*)
    +					# don't know the type
    +					doV "${FIELD}" "VALUE" "${FIELD}" "" "${NEW_FILE}"
    +					;;
    +			esac
    +		done
     }
     
    -####
    -# We can't automatically determine the latitude and longitude, so prompt for them.
    -get_lat_long()
    +
    +
    +# Copy everything from old config.sh to the settings file.
    +convert_config_sh()
     {
    -	if [[ ! -f ${SETTINGS_FILE} ]]; then
    -		display_msg --log error "INTERNAL ERROR: '${SETTINGS_FILE}' not found!"
    +	local OLD_CONFIG_FILE="${1}"
    +	local NEW_FILE="${2}"
    +	local CALLED_FROM="${3}"
    +
    +	if [[ ${CALLED_FROM} == "install" ]]; then
    +		declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
    +	fi
    +
    +	if [[ ! -e ${OLD_CONFIG_FILE} ]]; then
    +		display_msg --log info "No prior config.sh file to process."
     		return 1
     	fi
     
    -	display_msg --log progress "Prompting for Latitude and Longitude."
    +	MSG="Copying prior config.sh settings to settings file:"
    +	display_msg --log progress "${MSG}"
    +	(		# Use (  and not {  so the source'd variables don't stay in our environment
    +		#shellcheck disable=SC1090
    +		if ! source "${OLD_CONFIG_FILE}" ; then
    +			display_msg --log error "Unable to process prior config.sh file (${OLD_CONFIG_FILE})."
    +			return 1
    +		fi
    +
    +		local X		# temporary variable
    +
    +		# Determine name of settings to capture/save daytime images.
    +		# They initially were DAYTIME/CAPTURE_24HR, then DAYTIME_CAPTURE/DAYTIME_SAVE,
    +		# then moved to settings file.
    +		X=""
    +		if [[ -n ${DAYTIME_CAPTURE} ]]; then
    +			X="DAYTIME_CAPTURE"
    +		elif [[ -n ${DAYTIME} ]]; then
    +			X="DAYTIME"
    +			if [[ ${DAYTIME} == "1" ]]; then
    +				DAYTIME="true"
    +			else
    +				DAYTIME="false"
    +			fi
    +		fi
    +		[[ -n ${X} ]] && doV "${X}" "X" "takedaytimeimages" "boolean" "${NEW_FILE}"
     
    -	MSG="Enter your Latitude."
    -	MSG="${MSG}\nIt can either have a plus or minus sign (e.g., -20.1)"
    -	MSG="${MSG}\nor N or S (e.g., 20.1S)"
    -	LATITUDE="$(prompt_for_lat_long "${MSG}" "latitude" "Latitude")"
    +		X=""
    +		if [[ -n ${DAYTIME_SAVE} ]]; then
    +			X="DAYTIME_SAVE"
    +		elif [[ -n ${CAPTURE_24HR} ]]; then
    +			X="CAPTURE_24HR"
    +		fi
    +		[[ -n ${X} ]] && doV "${X}" "X" "savedaytimeimages" "boolean" "${NEW_FILE}"
     
    -	MSG="Enter your Longitude."
    -	MSG="${MSG}\nIt can either have a plus or minus sign (e.g., -20.1)"
    -	MSG="${MSG}\nor E or W (e.g., 20.1W)"
    -	LONGITUDE="$(prompt_for_lat_long "${MSG}" "longitude" "Longitude")"
    +		doV "" "DARK_FRAME_SUBTRACTION" "usedarkframes" "boolean" "${NEW_FILE}"
     
    -	if [[ -z ${LATITUDE} || -z ${LONGITUDE} ]]; then
    -		display_msg --log warning "Latitude and longitude need to be set in the WebUI before Allsky can start."
    +		# IMG_UPLOAD no longer used; instead, upload if FREQUENCY > 0.
    +		# shellcheck disable=SC2034
    +		[[ ${IMG_UPLOAD} != "true" ]] && IMG_UPLOAD_FREQUENCY=0
    +		doV "" "IMG_UPLOAD_FREQUENCY" "imageuploadfrequency" "number" "${NEW_FILE}"
    +
    +		# IMG_RESIZE no longer used; only resize if width and height are > 0.
    +		if [[ ${IMG_RESIZE} != "true" ]]; then
    +			IMG_WIDTH=0
    +			IMG_HEIGHT=0
    +		else
    +			IMG_WIDTH="${IMG_WIDTH:-0}"
    +			IMG_HEIGHT="${IMG_HEIGHT:-0}"
    +		fi
    +		doV "" "IMG_WIDTH" "imageresizewidth" "number" "${NEW_FILE}"
    +		doV "" "IMG_HEIGHT" "imageresizeheight" "number" "${NEW_FILE}"
    +
    +		# CROP_IMAGE, CROP_WIDTH, CROP_HEIGHT, CROP_OFFSET_X, and CROP_OFFSET_Y are no longer used.
    +		# Instead the user specifies the number of pixels to crop from the
    +		# top, right, bottom, and left.
    +		# It's too difficult to convert old numbers to new, so force the user to enter new numbers.
    +		# We'd need to know actual number of image pixels, bin level, and .width and .height to get
    +		# the effective width and height, then convert.
    +		if [[ ${CROP_IMAGE} == "true" ]]; then
    +			MSG="The way to specify cropping images has changed."
    +			MSG+=" You need to reenter your crop settings."
    +			MSG+="\n  Specify the amount to crop from the top, right, bottom, and left."
    +			display_msg --log info "${MSG}"
    +		fi
    +		X=0
    +		doV "NEW" "X" "imagecroptop" "number" "${NEW_FILE}"
    +		doV "NEW" "X" "imagecropright" "number" "${NEW_FILE}"
    +		doV "NEW" "X" "imagecropbottom" "number" "${NEW_FILE}"
    +		doV "NEW" "X" "imagecropleft" "number" "${NEW_FILE}"
    +
    +		# AUTO_STRETCH no longer used; only stretch if AMOUNT > 0 and MID_POINT != ""
    +		X=0; doV "NEW" "X" "imagestretchamountdaytime" "number" "${NEW_FILE}"
    +		X=10; doV "NEW" "X" "imagestretchmidpointdaytime" "number" "${NEW_FILE}"
    +		# shellcheck disable=SC2034
    +		[[ ${AUTO_STRETCH} != "true" || -z ${AUTO_STRETCH_MID_POINT} ]] && AUTO_STRETCH_AMOUNT=0
    +		doV "" "AUTO_STRETCH_AMOUNT" "imagestretchamountnighttime" "number" "${NEW_FILE}"
    +		AUTO_STRETCH_MID_POINT="${AUTO_STRETCH_MID_POINT/\%/}"	# % no longer used
    +		doV "" "AUTO_STRETCH_MID_POINT" "imagestretchmidpointnighttime" "number" "${NEW_FILE}"
    +
    +		# RESIZE_UPLOADS no longer used; resize only if width > 0 and height > 0.
    +		if [[ ${RESIZE_UPLOADS} != "true" ]]; then
    +			# shellcheck disable=SC2034
    +			RESIZE_UPLOADS_WIDTH=0
    +			# shellcheck disable=SC2034
    +			RESIZE_UPLOADS_HEIGHT=0
    +		fi
    +		doV "" "RESIZE_UPLOADS_WIDTH" "imageresizeuploadswidth" "number" "${NEW_FILE}"
    +		doV "" "RESIZE_UPLOADS_HEIGHT" "imageresizeuploadsheight" "number" "${NEW_FILE}"
    +
    +		doV "" "IMG_CREATE_THUMBNAILS" "imagecreatethumbnails" "boolean" "${NEW_FILE}"
    +
    +		# REMOVE_BAD_IMAGES no longer used; remove only if low > 0 or high > 0.
    +		if [[ ${REMOVE_BAD_IMAGES} != "true" ]]; then
    +			# shellcheck disable=SC2034
    +			REMOVE_BAD_IMAGES_THRESHOLD_LOW=0
    +			# shellcheck disable=SC2034
    +			REMOVE_BAD_IMAGES_THRESHOLD_HIGH=0
    +		else
    +			# Old settings were 0 to 100, new are 0.0 to 1.0
    +			REMOVE_BAD_IMAGES_THRESHOLD_LOW="$( awk -v n="${REMOVE_BAD_IMAGES_THRESHOLD_LOW}" \
    +				'BEGIN { printf("%.3f", n/100); exit 0; }' )"
    +			REMOVE_BAD_IMAGES_THRESHOLD_HIGH="$( awk -v n="${REMOVE_BAD_IMAGES_THRESHOLD_HIGH}" \
    +				'BEGIN { printf("%.3f", n/100); exit 0; }' )"
    +		fi
    +		doV "" "REMOVE_BAD_IMAGES_THRESHOLD_LOW" "imageremovebadlow" "number" "${NEW_FILE}"
    +		doV "" "REMOVE_BAD_IMAGES_THRESHOLD_HIGH" "imageremovebadhigh" "number" "${NEW_FILE}"
    +
    +		doV "" "TIMELAPSE" "timelapsegenerate" "boolean" "${NEW_FILE}"
    +		doV "" "TIMELAPSEWIDTH" "timelapsewidth" "number" "${NEW_FILE}"
    +		doV "" "TIMELAPSEHEIGHT" "timelapseheight" "number" "${NEW_FILE}"
    +		# We no longer include the trailing "k".
    +		TIMELAPSE_BITRATE="${TIMELAPSE_BITRATE/k/}"
    +		doV "" "TIMELAPSE_BITRATE" "timelapsebitrate" "number" "${NEW_FILE}"
    +		doV "" "FPS" "timelapsefps" "number" "${NEW_FILE}"
    +		doV "" "VCODEC" "timelapsevcodec" "text" "${NEW_FILE}"
    +		doV "" "PIX_FMT" "timelapsepixfmt" "text" "${NEW_FILE}"
    +		doV "" "FFLOG" "timelapsefflog" "text" "${NEW_FILE}"
    +		doV "" "KEEP_SEQUENCE" "timelapsekeepsequence" "boolean" "${NEW_FILE}"
    +		doV "" "TIMELAPSE_EXTRA_PARAMETERS" "timelapseextraparameters" "text" "${NEW_FILE}"
    +		doV "" "UPLOAD_VIDEO" "timelapseupload" "boolean" "${NEW_FILE}"
    +		doV "" "TIMELAPSE_UPLOAD_THUMBNAIL" "timelapseuploadthumbnail" "boolean" "${NEW_FILE}"
    +
    +		doV "" "TIMELAPSE_MINI_IMAGES" "minitimelapsenumimages" "number" "${NEW_FILE}"
    +		doV "" "TIMELAPSE_MINI_FORCE_CREATION" "minitimelapseforcecreation" "boolean" "${NEW_FILE}"
    +		doV "" "TIMELAPSE_MINI_FREQUENCY" "minitimelapsefrequency" "number" "${NEW_FILE}"
    +		doV "" "TIMELAPSE_MINI_UPLOAD_VIDEO" "minitimelapseupload" "boolean" "${NEW_FILE}"
    +		doV "" "TIMELAPSE_MINI_UPLOAD_THUMBNAIL" "minitimelapseuploadthumbnail" "boolean" "${NEW_FILE}"
    +		doV "" "TIMELAPSE_MINI_FPS" "minitimelapsefps" "number" "${NEW_FILE}"
    +		TIMELAPSE_MINI_BITRATE="${TIMELAPSE_MINI_BITRATE//k/}"
    +		doV "" "TIMELAPSE_MINI_BITRATE" "minitimelapsebitrate" "number" "${NEW_FILE}"
    +		doV "" "TIMELAPSE_MINI_WIDTH" "minitimelapsewidth" "number" "${NEW_FILE}"
    +		doV "" "TIMELAPSE_MINI_HEIGHT" "minitimelapseheight" "number" "${NEW_FILE}"
    +
    +		doV "" "KEOGRAM" "keogramgenerate" "boolean" "${NEW_FILE}"
    +		doV "" "KEOGRAM_EXTRA_PARAMETERS" "keogramextraparameters" "text" "${NEW_FILE}"
    +		doV "" "UPLOAD_KEOGRAM" "keogramupload" "boolean" "${NEW_FILE}"
    +		X="true"; doV "NEW" "X" "keogramexpand" "boolean" "${NEW_FILE}"
    +		X="simplex"; doV "NEW" "X" "keogramfontname" "text" "${NEW_FILE}"
    +		X="#ffff"; doV "NEW" "X" "keogramfontcolor" "text" "${NEW_FILE}"
    +		X=1; doV "NEW" "X" "keogramfontsize" "text" "${NEW_FILE}"
    +		X=3; doV "NEW" "X" "keogramlinethickness" "text" "${NEW_FILE}"
    +
    +		doV "" "STARTRAILS" "startrailsgenerate" "boolean" "${NEW_FILE}"
    +		doV "" "BRIGHTNESS_THRESHOLD" "startrailsbrightnessthreshold" "number" "${NEW_FILE}"
    +		doV "" "STARTRAILS_EXTRA_PARAMETERS" "startrailsextraparameters" "text" "${NEW_FILE}"
    +		doV "" "UPLOAD_STARTRAILS" "startrailsupload" "boolean" "${NEW_FILE}"
    +
    +		[[ -z ${THUMBNAIL_SIZE_X} ]] && THUMBNAIL_SIZE_X=100
    +		doV "" "THUMBNAIL_SIZE_X" "thumbnailsizex" "number" "${NEW_FILE}"
    +		[[ -z ${THUMBNAIL_SIZE_Y} ]] && THUMBNAIL_SIZE_Y=75
    +		doV "" "THUMBNAIL_SIZE_Y" "thumbnailsizey" "number" "${NEW_FILE}"
    +
    +		# NIGHTS_TO_KEEP was replaced by DAYS_TO_KEEP and the AUTO_DELETE boolean was deleted.
    +		if [[ -n ${NIGHTS_TO_KEEP} && ${AUTO_DELETE} == "true" ]]; then
    +			doV "" "NIGHTS_TO_KEEP" "daystokeep" "number" "${NEW_FILE}"
    +		else
    +			doV "" "DAYS_TO_KEEP" "daystokeep" "number" "${NEW_FILE}"
    +		fi
    +		doV "" "WEB_DAYS_TO_KEEP" "daystokeeplocalwebsite" "number" "${NEW_FILE}"
    +		X=0; doV "NEW" "X" "daystokeepremotewebsite" "number" "${NEW_FILE}"
    +		doV "" "WEBUI_DATA_FILES" "webuidatafiles" "text" "${NEW_FILE}"
    +
    +	) || return 1
    +
    +	if [[ ${CALLED_FROM} == "install" ]]; then
    +		STATUS_VARIABLES+=( "${FUNCNAME[0]}='true'\n" )
     	fi
    +
     	return 0
     }
     
    +# Copy everything from old ftp-settings.sh to the settings file.
    +convert_ftp_sh()
    +{
    +	local FTP_FILE="${1}"
    +	local NEW_FILE="${2}"
    +	local CALLED_FROM="${3}"
     
    -####
    -# Convert the prior settings file to a new one,
    -# then link the new one to the camera-specific name.
    -convert_settings()			# prior_version, new_version, prior_file, new_file
    -{
    -	PRIOR_VERSION="${1}"
    -	NEW_VERSION="${2}"
    -		NEW_BASE_VERSION="${NEW_VERSION:0:11}"		# without point release
    -	PRIOR_FILE="${3}"
    -	NEW_FILE="${4}"
    -
    -	[[ ${NEW_VERSION} == "${PRIOR_VERSION}" ]] && return
    -
    -	# TODO: new versions go here
    -
    -	if [[ ${NEW_BASE_VERSION} == "v2023.05.01" ]]; then
    -
    -		# Replaced "meanthreshold" with "daymeanthreshold" and "nightmeanthreshold"
    -		# if they don't already exist.
    -		# They were added in v2023.05.01_02.
    -		local F="meanthreshold"
    -		DAYMEANTHRESHOLD="$( settings ".day${F}" "${NEW_FILE}" )"
    -		NIGHTMEANTHRESHOLD="$( settings ".night${F}" "${NEW_FILE}" )"
    -		if [[ -n ${DAYMEANSETTING} && -n ${NIGHTMEANSETTING} ]]; then
    -			display_msg --logonly info "   day and night '${F}' already exist."
    -			return
    -		fi
    -
    -		MEANTHRESHOLD="$( settings ".${F}" "${PRIOR_FILE}" )"
    -		if [[ -n ${MEANTHRESHOLD} ]]; then
    -			if [[ -z ${DAYMEANTHRESHOLD} ]]; then
    -				display_msg --logonly info "   Updating 'day${F}' in '${NEW_FILE}'."
    -				update_json_file ".day${F}" "${MEANTHRESHOLD}" "${NEW_FILE}"
    -			fi
    -			if [[ -z ${NIGHTMEANTHRESHOLD} ]]; then
    -				display_msg --logonly info "   Updating 'night${F}' in '${NEW_FILE}'."
    -				update_json_file ".night${F}" "${MEANTHRESHOLD}" "${NEW_FILE}"
    -			fi
    +	if [[ ${CALLED_FROM} == "install" ]]; then
    +		declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
    +	fi
     
    -			# If ${F} exists in the new file
    -			MEANTHRESHOLD="$( settings ".${F}" "${NEW_FILE}" )"
    -			if [[ -n ${MEANTHRESHOLD} ]]; then
    -				display_msg --logonly info "   Deleting '${F}' from '${NEW_FILE}'."
    -				sed -i "/\"${F}\"/d" "${NEW_FILE}"
    -			fi
    -		else
    -			display_msg --logonly info "   '${F}' was not in prior settings file."
    +	if [[ ! -e ${FTP_FILE} ]]; then
    +		display_msg --log info "No prior ftp-settings.sh file to process (${FTP_FILE})."
    +		return 1
    +	fi
    +
    +	MSG="Copying prior ftp-settings.sh settings to settings file:"
    +	display_msg --log progress "${MSG}"
    +	(		# Use (  and not {  so the source'd variables don't stay in our environment
    +
    +		# These are really old names from the ftp file.
    +		# Make sure they aren't set before source'ing the file in.
    +		unset HOST USER PASSWORD IMGDIR MP4DIR
    +
    +		#shellcheck disable=SC1090
    +		if ! source "${FTP_FILE}" ; then
    +			display_msg --log error "Unable to process prior ftp-settings.sh file (${FTP_FILE})."
    +			return 1
     		fi
     
    -		return
    -	fi
    +		PROTOCOL="${PROTOCOL,,}"
    +
    +		# Really old names:
    +		# shellcheck disable=SC2034
    +		[[ -n ${HOST} ]] && REMOTE_HOST="${HOST}"
    +		# shellcheck disable=SC2034
    +		[[ -n ${USER} ]] && REMOTE_USER="${USER}"
    +		# shellcheck disable=SC2034
    +		[[ -n ${PASSWORD} ]] && REMOTE_PASSWORD="${PASSWORD}"
    +		# shellcheck disable=SC2034
    +		[[ -n ${IMGDIR} ]] && IMAGE_DIR="${IMGDIR}"
    +		# shellcheck disable=SC2034
    +		[[ -n ${MP4DIR} ]] && VIDEOS_DIR="${MP4DIR}"
    +
    +		# ALLSKY_ENV is used by a remote Website and/or server.
    +		# Since we update it below, make sure it exists.
    +		if [[ ! -f ${ALLSKY_ENV} ]]; then
    +			cp "${REPO_ENV_FILE}" "${ALLSKY_ENV}"
    +		fi
     
    -	if [[ ${NEW_BASE_VERSION} == "v2023.05.01" && ${PRIOR_VERSION} == "v2022.03.01" ]]; then
    -		local B="$( basename "${NEW_FILE}" )"
    -		local NAME="${B%.*}"			# before "."
    -		local EXT="${B##*.}"			# after "."
    -		local SPECIFIC="${NAME}_${CAMERA_TYPE}_${CAMERA_MODEL}.${EXT}"
    +		# Ignore the WEB_*_DIR entries - the user can no longer specify local directories.
    +		# Ignore VIDEOS_DIR, KEOGRAM_DIR, STARTRAILS_DIR - the user can no longer specify them.
    +		# Don't update REMOTEWEBSITE_* settings since they are new so have no prior value.
     
    -		# For each field in prior file, update new file with old value.
    -		# Then handle new fields and fields that changed locations or names.
    -		# convert_json_to_tabs outputs fields and values separated by tabs.
    +		# "local" PROTOCOL means they're using local Website.
    +		# WEB_IMAGE_DIR means they have both local and remote Website.
    +		PROTOCOL="${PROTOCOL,,}"
    +		if [[ (${PROTOCOL} == "local" || -n ${WEB_IMAGE_DIR}) ]]; then
    +			X="true"
    +		else
    +			X="false"
    +		fi
    +		doV "NEW" "X" "uselocalwebsite" "boolean" "${NEW_FILE}"
     
    -		convert_json_to_tabs "${PRIOR_FILE}" |
    -			while read -r F V
    -			do
    -				case "${F,,}" in
    -					"lastchanged")
    -						V="$( date +'%Y-%m-%d %H:%M:%S' )"
    -						;;
    -
    -					# These don't exist anymore.
    -					"autofocus"|"background")
    -						continue;
    -						;;
    -
    -					# These changed names.
    -					"darkframe")
    -						F="takeDarkFrames"
    -						;;
    -					"daymaxautoexposure")
    -						F="daymaxautoexposure"
    -						;;
    -					"daymaxgain")
    -						F="daymaxautogain"
    -						;;
    -					"nightmaxautoexposure")
    -						F="nightmaxautoexposure"
    -						;;
    -					"nightmaxgain")
    -						F="nightmaxautogain"
    -						;;
    -
    -					# These now have day and night versions.
    -					"brightness")
    -						update_json_file ".day${F}" "${V}" "${NEW_FILE}"
    -						F="night${F}"
    -						;;
    -					"awb"|"autowhitebalance")
    -						update_json_file ".day${F}" "${V}" "${NEW_FILE}"
    -						F="night${F}"
    -						;;
    -					"wbr")
    -						update_json_file ".day${F}" "${V}" "${NEW_FILE}"
    -						F="night${F}"
    -						;;
    -					"wbb")
    -						update_json_file ".day${F}" "${V}" "${NEW_FILE}"
    -						F="night${F}"
    -						;;
    -					"targettemp")
    -						F="TargetTemp"
    -						update_json_file ".day${F}" "${V}" "${NEW_FILE}"
    -						F="night${F}"
    -						;;
    -					"coolerenabled")
    -						F="EnableCooler"
    -						update_json_file ".day${F}" "${V}" "${NEW_FILE}"
    -						F="night${F}"
    -						;;
    -					"meanthreshold")
    -						F="meanthreshold"
    -						update_json_file ".day${F}" "${V}" "${NEW_FILE}"
    -						F="night${F}"
    -						;;
    -				esac
    -
    -				update_json_file ".${F}" "${V}" "${NEW_FILE}"
    -			done
    +		##### Remote Website
    +		if [[ (-n ${PROTOCOL} && ${PROTOCOL} != "local") || -n ${REMOTE_HOST} ]]; then
    +			doV "" "PROTOCOL" "remotewebsiteprotocol" "text" "${NEW_FILE}"
    +			doV "" "IMAGE_DIR" "remotewebsiteimagedir" "text" "${NEW_FILE}"
    +			X="true"
    +		else
    +			PROTOCOL="${PROTOCOL:-ftps}"	# WebUI complains if it's not set
    +			doV "" "PROTOCOL" "remotewebsiteprotocol" "text" "${NEW_FILE}"
    +			# shellcheck disable=SC2034
    +			IMAGE_DIR=""
    +			doV "" "IMAGE_DIR" "remotewebsiteimagedir" "text" "${NEW_FILE}"
    +			X="false"
    +		fi
    +		doV "NEW" "X" "useremotewebsite" "boolean" "${NEW_FILE}"
    +
    +		doV "" "IMG_UPLOAD_ORIGINAL_NAME" "remotewebsiteimageuploadoriginalname" "boolean" "${NEW_FILE}"
    +		doV "" "VIDEOS_DESTINATION_NAME" "remotewebsitevideodestinationname" "text" "${NEW_FILE}"
    +		doV "" "KEOGRAM_DESTINATION_NAME" "remotewebsitekeogramdestinationname" "text" "${NEW_FILE}"
    +		doV "" "STARTRAILS_DESTINATION_NAME" "remotewebsitestartrailsdestinationname" "text" "${NEW_FILE}"
    +		doV "" "REMOTE_HOST" "REMOTEWEBSITE_HOST" "text" "${ALLSKY_ENV}"
    +		doV "" "REMOTE_PORT" "REMOTEWEBSITE_PORT" "text" "${ALLSKY_ENV}"
    +
    +		doV "" "REMOTE_USER" "REMOTEWEBSITE_USER" "text" "${ALLSKY_ENV}"
    +		doV "" "REMOTE_PASSWORD" "REMOTEWEBSITE_PASSWORD" "text" "${ALLSKY_ENV}"
    +		doV "" "LFTP_COMMANDS" "REMOTEWEBSITE_LFTP_COMMANDS" "text" "${ALLSKY_ENV}"
    +		doV "" "SSH_KEY_FILE" "REMOTEWEBSITE_SSH_KEY_FILE" "text" "${ALLSKY_ENV}"
    +
    +		if [[ ${PROTOCOL} != "s3" ]]; then
    +			AWS_CLI_DIR=""
    +			# shellcheck disable=SC2034
    +			S3_BUCKET=""
    +		fi
    +		doV "" "AWS_CLI_DIR" "REMOTEWEBSITE_AWS_CLI_DIR" "text" "${ALLSKY_ENV}"
    +		doV "" "S3_BUCKET" "REMOTEWEBSITE_S3_BUCKET" "text" "${ALLSKY_ENV}"
    +		doV "" "S3_ACL" "REMOTEWEBSITE_S3_ACL" "text" "${ALLSKY_ENV}"
     
    -		# Fields whose location changed.
    -		x="$( get_variable "DAYTIME_CAPTURE" "${PRIOR_CONFIG_FILE}" )"
    -		update_json_file ".takeDaytimeImages" "${x}" "${NEW_FILE}"
    +		if [[ ${PROTOCOL} != "gcs" ]]; then
    +			# shellcheck disable=SC2034
    +			GCS_BUCKET=""
    +		fi
    +		doV "" "GCS_BUCKET" "REMOTEWEBSITE_GCS_BUCKET" "text" "${ALLSKY_ENV}"
    +		doV "" "GCS_ACL" "REMOTEWEBSITE_GCS_ACL" "text" "${ALLSKY_ENV}"
     
    -		x="$( get_variable "DAYTIME_SAVE" "${PRIOR_CONFIG_FILE}" )"
    -		update_json_file ".saveDaytimeImages" "${x}" "${NEW_FILE}"
    +		##### Remote server - wasn't in prior releases so don't need to update ${ALLSKY_ENV}.
    +		X="false"; doV "NEW" "X" "useremoteserver" "boolean" "${NEW_FILE}"
    +		X="ftps"; doV "NEW" "X" "remoteserverprotocol" "text" "${NEW_FILE}"
    +		X="false"; doV "NEW" "X" "remoteserverimageuploadoriginalname" "boolean" "${NEW_FILE}"
    +	)
     
    -		x="$( get_variable "DARK_FRAME_SUBTRACTION" "${PRIOR_CONFIG_FILE}" )"
    -		update_json_file ".useDarkFrames" "${x}" "${NEW_FILE}"
    -	fi
    -}
    +	[[ ${CALLED_FROM} == "install" ]] && STATUS_VARIABLES+=( "${FUNCNAME[0]}='true'\n" )
     
    +	return 0
    +}
     
     ####
     # Restore the prior settings file(s) if the user wanted to use them.
    -# For newStyle we restore all prior camera-specific file(s) and let makeChanges.sh create
    -# the new settings file, linking it to the appropriate camera-specific file.
    -# For oldStyle (which has no camera-specific file) we update the settings file if it currently exists.
    +# For ${NEW_STYLE_ALLSKY} we restore all prior camera-specific file(s) and let makeChanges.sh
    +# create the new settings file, linking it to the appropriate camera-specific file.
    +# For ${OLD_STYLE_ALLSKY} (which has no camera-specific file) we update the settings file
    +# if it currently exists.
    +
     restore_prior_settings_file()
     {
     	[[ ${RESTORED_PRIOR_SETTINGS_FILE} == "true" ]] && return
     
    -	STATUS_VARIABLES+=( "RESTORED_PRIOR_SETTINGS_FILE='${RESTORED_PRIOR_SETTINGS_FILE}'\n" )
    -
     	if [[ ! -f ${PRIOR_SETTINGS_FILE} ]]; then
    -		# This should "never" happen.
    -		# Huh?  No prior settings file ?
    +		# This should "never" happen since we are only called if the file exists.
     		display_msg --log error "Prior settings file missing: ${PRIOR_SETTINGS_FILE}."
     		FORCE_CREATING_DEFAULT_SETTINGS_FILE="true"
     		return
     	fi
     
    -	local MSG NAME EXT FIRST_ONE
    +	local MSG  NAME  EXT  FIRST_ONE  CHECK_UPPER
     
    -	if [[ ${PRIOR_ALLSKY} == "newStyle" ]]; then
    +	if [[ ${PRIOR_ALLSKY_STYLE} == "${NEW_STYLE_ALLSKY}" ]]; then
     		# The prior settings file SHOULD be a link to a camera-specific file.
     		# Make sure that's true; if not, fix it.
     
    -		MSG="Checking link for newStyle PRIOR_SETTINGS_FILE '${PRIOR_SETTINGS_FILE}'"
    +		MSG="Checking link for ${NEW_STYLE_ALLSKY} PRIOR_SETTINGS_FILE '${PRIOR_SETTINGS_FILE}'"
     		display_msg --logonly info "${MSG}"
    -		MSG="$( check_settings_link "${PRIOR_SETTINGS_FILE}" )"
    +
    +		# Do we need to check for upperCase or lowercase setting names?
    +		if [[ ${PRIOR_ALLSKY_BASE_VERSION} < "${COMBINED_BASE_VERSION}" ]]; then
    +			CHECK_UPPER="--uppercase"
    +		else
    +			CHECK_UPPER=""
    +		fi
    +
    +		# shellcheck disable=SC2086
    +		MSG="$( check_settings_link ${CHECK_UPPER} "${PRIOR_SETTINGS_FILE}" )"
     		if [[ $? -eq "${EXIT_ERROR_STOP}" ]]; then
     			display_msg --log error "${MSG}"
     			FORCE_CREATING_DEFAULT_SETTINGS_FILE="true"
    @@ -1788,17 +2168,17 @@ restore_prior_settings_file()
     		# Copy all the camera-specific settings files; don't copy the generic-named
     		# file since it will be recreated.
     		# There will be more than one camera-specific file if the user has multiple cameras.
    -		local PRIOR_SPECIFIC_FILES="$(find "${PRIOR_CONFIG_DIR}" -name "${NAME}_"'*'".${EXT}")"
    +		local PRIOR_SPECIFIC_FILES="$( find "${PRIOR_CONFIG_DIR}" -name "${NAME}_"'*'".${EXT}" )"
     		if [[ -n ${PRIOR_SPECIFIC_FILES} ]]; then
     			FIRST_ONE="true"
    -			echo "${PRIOR_SPECIFIC_FILES}" | while read -r F
    +			echo "${PRIOR_SPECIFIC_FILES}" | while read -r FILE
     				do
     					if [[ ${FIRST_ONE} == "true" ]]; then
     						display_msg --log progress "Restoring camera-specific settings files:"
     						FIRST_ONE="false"
     					fi
    -					display_msg --log progress "\t$(basename "${F}")"
    -					cp -a "${F}" "${ALLSKY_CONFIG}"
    +					display_msg --log progress "\t$( basename "${FILE}" )"
    +					cp -a "${FILE}" "${ALLSKY_CONFIG}"
     				done
     			RESTORED_PRIOR_SETTINGS_FILE="true"
     			FORCE_CREATING_DEFAULT_SETTINGS_FILE="false"
    @@ -1808,13 +2188,14 @@ restore_prior_settings_file()
     
     			# Try to create one based on ${PRIOR_SETTINGS_FILE}.
     			if [[ ${PRIOR_CAMERA_TYPE} != "${CAMERA_TYPE}" ]]; then
    -				MSG="${MSG}\nand unable to create one: new Camera Type"
    -				MSG="${MSG} (${CAMERA_TYPE} different from prior type (${PRIOR_CAMERA_TYPE})."
    +# TODO: ? check CAMERA_MODEL
    +				MSG+="\nand unable to create one: new Camera Type"
    +				MSG+=" (${CAMERA_TYPE} different from prior type (${PRIOR_CAMERA_TYPE})."
     				FORCE_CREATING_DEFAULT_SETTINGS_FILE="true"
     			else
     				local SPECIFIC="${NAME}_${PRIOR_CAMERA_TYPE}_${PRIOR_CAMERA_MODEL}.${EXT}"
     				cp -a "${PRIOR_SETTINGS_FILE}" "${ALLSKY_CONFIG}/${SPECIFIC}"
    -				MSG="${MSG}\nbut was able to create '${SPECIFIC}'."
    +				MSG+="\nbut was able to create '${SPECIFIC}'."
     				PRIOR_SPECIFIC_FILES="${SPECIFIC}"
     
     				RESTORED_PRIOR_SETTINGS_FILE="true"
    @@ -1828,14 +2209,14 @@ restore_prior_settings_file()
     			  ${PRIOR_ALLSKY_VERSION} != "${ALLSKY_VERSION}" ]]; then
     			for S in ${PRIOR_SPECIFIC_FILES}
     			do
    -				# Update all the prior camera-specific files (which are now in $ALLSKY_CONFIG).
    +				# Update all the prior camera-specific files (which are now in ${ALLSKY_CONFIG}).
     				# The new settings file will be based on a camera specific file.
    -				local B="$( basename "${S}" )"
    -				S="${ALLSKY_CONFIG}/${B}"
    -				display_msg --log progress "Updating '${S}'"
    -				convert_settings "${PRIOR_ALLSKY_VERSION}" "${ALLSKY_VERSION}" \
    -					"${S}" "${S}"
    +				S="${ALLSKY_CONFIG}/$( basename "${S}" )"
    +				convert_settings "${S}" "${S}" "install"
     			done
    +		else
    +			MSG="No need to update prior settings files - same Allsky version."
    +			display_msg --logonly info "${MSG}"
     		fi
     
     	else
    @@ -1844,16 +2225,16 @@ restore_prior_settings_file()
     			# Transfer prior settings to the new file.
     
     			case "${PRIOR_ALLSKY_VERSION}" in
    -				"v2022.03.01")
    -					convert_settings "${PRIOR_ALLSKY_VERSION}" "${ALLSKY_VERSION}" \
    -						"${PRIOR_SETTINGS_FILE}" "${SETTINGS_FILE}"
    +				"${FIRST_VERSION_VERSION}")
    +					convert_settings "${PRIOR_SETTINGS_FILE}" "${SETTINGS_FILE}" "install"
     
     					MSG="Your old WebUI settings were transfered to the new release,"
    -					MSG="${MSG}\n but note that there have been some changes to the settings file."
    -					MSG="${MSG}\n\nCheck your settings in the WebUI's 'Allsky Settings' page."
    +					MSG+="\n but note that there have been some changes to the settings file"
    +					MSG+=" (e.g., settings in ftp-settings.sh are now in the settings file)."
    +					MSG+="\n\nCheck your settings in the WebUI's 'Allsky Settings' page."
     					whiptail --title "${TITLE}" --msgbox "${MSG}" 18 "${WT_WIDTH}" 3>&1 1>&2 2>&3
     					display_msg info "\n${MSG}\n"
    -					echo -e "\n\n==========\n${MSG}" >> "${POST_INSTALLATION_ACTIONS}"
    +					echo -e "\n\n========== ACTION NEEDED:\n${MSG}" >> "${POST_INSTALLATION_ACTIONS}"
     					display_msg --logonly info "Settings from ${PRIOR_ALLSKY_VERSION} copied over."
     					;;
     
    @@ -1864,28 +2245,31 @@ restore_prior_settings_file()
     					# As far as I know, latitude, longitude, and angle have never changed names,
     					# and are required and have no default,
     					# so try to restore them so Allsky can restart automatically.
    +					# shellcheck disable=SC2034
     					local LAT="$( settings .latitude "${PRIOR_SETTINGS_FILE}" )"
    -					update_json_file ".latitude" "${LAT}" "${SETTINGS_FILE}"
    +					X="LAT"; doV "latitude" "X" "latitude" "text" "${SETTINGS_FILE}"
    +					# shellcheck disable=SC2034
     					local LONG="$( settings .longitude "${PRIOR_SETTINGS_FILE}" )"
    -					update_json_file ".longitude" "${LONG}" "${SETTINGS_FILE}"
    +					X="LONG"; doV "longitude" "X" "longitude" "text" "${SETTINGS_FILE}"
     					local ANGLE="$( settings .angle "${PRIOR_SETTINGS_FILE}" )"
    -					update_json_file ".angle" "${ANGLE}" "${SETTINGS_FILE}"
    +					X="ANGLE"; doV "angle" "X" "angle" "number" "${SETTINGS_FILE}"
     					display_msg --log progress "Prior latitude, longitude, and angle restored."
     
     					MSG="You need to manually transfer your old settings to the WebUI.\n"
    -					MSG="${MSG}\nNote that there have been many changes to the settings file"
    -					MSG="${MSG} since you last installed Allsky, so you will need"
    -					MSG="${MSG} to re-enter everything via the WebUI's 'Allsky Settings' page."
    +					MSG+="\nNote that there have been many changes to the settings file"
    +					MSG+=" since you last installed Allsky, so you will need"
    +					MSG+=" to re-enter everything via the WebUI's 'Allsky Settings' page."
     					whiptail --title "${TITLE}" --msgbox "${MSG}" 18 "${WT_WIDTH}" 3>&1 1>&2 2>&3
     					display_msg info "\n${MSG}\n"
    -					echo -e "\n\n==========\n${MSG}" >> "${POST_INSTALLATION_ACTIONS}"
    +					echo -e "\n\n========== ACTION NEEDED:\n${MSG}" >> "${POST_INSTALLATION_ACTIONS}"
    +
     					MSG="Only a few settings from very old ${PRIOR_ALLSKY_VERSION} copied over."
     					display_msg --logonly info "${MSG}"
     					;;
     			esac
     
     			# Set to null to force the user to look at the settings before Allsky will run.
    -			update_json_file ".lastChanged" "" "${SETTINGS_FILE}"
    +			update_json_file -d ".lastchanged" "" "${SETTINGS_FILE}"
     
     			RESTORED_PRIOR_SETTINGS_FILE="true"
     			FORCE_CREATING_DEFAULT_SETTINGS_FILE="false"
    @@ -1895,56 +2279,61 @@ restore_prior_settings_file()
     			FORCE_CREATING_DEFAULT_SETTINGS_FILE="true"
     		fi
     	fi
    +
    +	STATUS_VARIABLES+=( "RESTORED_PRIOR_SETTINGS_FILE='${RESTORED_PRIOR_SETTINGS_FILE}'\n" )
     }
     
     ####
     # If the user wanted to restore files from a prior version of Allsky, do that.
     restore_prior_files()
     {
    -	STATUS_VARIABLES+=( "restore_prior_files='true'\n" )
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
     
     	if [[ -d ${OLD_RASPAP_DIR} ]]; then
     		MSG="\nThe '${OLD_RASPAP_DIR}' directory is no longer used.\n"
    -		MSG="${MSG}When installation is done you may remove it by executing:\n"
    -		MSG="${MSG}    sudo rm -fr '${OLD_RASPAP_DIR}'\n"
    +		MSG+="When installation is done you may remove it by executing:\n"
    +		MSG+="    sudo rm -fr '${OLD_RASPAP_DIR}'\n"
     		display_msg --log info "${MSG}"
    -		echo -e "\n\n==========\n${MSG}" >> "${POST_INSTALLATION_ACTIONS}"
    +		echo -e "\n\n========== ACTION NEEDED:\n${MSG}" >> "${POST_INSTALLATION_ACTIONS}"
     	fi
     
    -	if [[ -z ${PRIOR_ALLSKY} ]]; then
    -		get_lat_long	# get them to put in new config file
    -		mkdir -p "${ALLSKY_EXTRA}"		# default permissions is ok
    +	if [[ ${USE_PRIOR_ALLSKY} == "false" ]]; then
    +		# prompt for them to put in new settings file
    +		if ! get_lat_long ; then
    +			MSG="Latitude and/or Longitude not entered"
    +			display_msg --log info "${MSG}"
    +			CONFIGURATION_NEEDED="${STATUS_NO_LAT_LONG}"
    +		fi
     
    +		STATUS_VARIABLES+=( "${FUNCNAME[0]}='true'\n" )
     		return			# Nothing left to do in this function, so return
     	fi
     
    -	# Do all the being restores, then all the updates.
    -	local V=""
    +	# If the prior ${ALLSKY_TMP} is mounted, unmount it so users can
    +	# remove the old Allsky.
    +	D="${PRIOR_ALLSKY_DIR}/tmp"
    +	if is_mounted "${D}" ; then
    +		display_msg --logonly info "Unmounting '${D}'."
    +		umount_tmp "${D}"
    +	fi
     
    +	# Do all the restores, then all the updates.
     	display_msg --log progress "Restoring prior:"
     
    -	local SPACE="    "
    -	local NOT_RESTORED="NO PRIOR VERSION"
    -	# TODO: endOfNight_additionalStepts.sh script is going away in the next major release.
    -	local ITEM="${SPACE}endOfNight_additionalSteps.sh"
    +	local E  D  R  ITEM  X
    +
     	if [[ -f ${PRIOR_ALLSKY_DIR}/scripts/endOfNight_additionalSteps.sh ]]; then
    -		display_msg --log progress "${ITEM}"
    -		cp -a "${PRIOR_ALLSKY_DIR}/scripts/endOfNight_additionalSteps.sh" "${ALLSKY_SCRIPTS}"
    -
    -		MSG="The ${ALLSKY_SCRIPTS}/endOfNight_additionalSteps.sh file will be removed"
    -		MSG="${MSG}\nin the next version of Allsky.  You appear to be using this file,"
    -		MSG="${MSG}\nso please move your code to the 'Script' module in"
    -		MSG="${MSG}\nthe 'Night to Day Transition Flow' of the Module Manager."
    -		MSG="${MSG}\nSee the 'Explanations --> Module' documentation for more details."
    +		MSG="The ${ALLSKY_SCRIPTS}/endOfNight_additionalSteps.sh file is no longer supported."
    +		MSG+="\nPlease move your code in that file to the 'Script' module in"
    +		MSG+="\nthe 'Night to Day Transition Flow' of the Module Manager."
    +		MSG+="\nSee the 'Explanations --> Module' documentation for more details."
     		display_msg --log warning "\n${MSG}\n"
    -		echo -e "\n\n==========\n${MSG}" >> "${POST_INSTALLATION_ACTIONS}"
    -	else
    -		display_msg --log progress "${ITEM}: ${NOT_RESTORED}"
    +		echo -e "\n\n========== ACTION NEEDED:\n${MSG}" >> "${POST_INSTALLATION_ACTIONS}"
     	fi
     
     	ITEM="${SPACE}'images' directory"
     	if [[ -d ${PRIOR_ALLSKY_DIR}/images ]]; then
    -		display_msg --log progress "${ITEM}"
    +		display_msg --log progress "${ITEM} (moving)"
     		mv "${PRIOR_ALLSKY_DIR}/images" "${ALLSKY_HOME}"
     	else
     		# This is probably very rare so let the user know
    @@ -1953,508 +2342,895 @@ restore_prior_files()
     
     	ITEM="${SPACE}'darks' directory"
     	if [[ -d ${PRIOR_ALLSKY_DIR}/darks ]]; then
    -		display_msg --log progress "${ITEM}"
    +		display_msg --log progress "${ITEM} (moving)"
     		mv "${PRIOR_ALLSKY_DIR}/darks" "${ALLSKY_HOME}"
     	else
     		display_msg --log progress "${ITEM}: ${NOT_RESTORED}"
     	fi
     
    -	ITEM="${SPACE}'modules' directory"
    -	if [[ -d ${PRIOR_CONFIG_DIR}/modules ]]; then
    -		display_msg --log progress "${ITEM}"
    +	ITEM="${SPACE}'$( basename "$( dirname "${ALLSKY_MYFILES_DIR}" )" )/${ALLSKY_MYFILES_NAME}' directory"
    +	if [[ -d ${PRIOR_MYFILES_DIR} ]]; then
    +		display_msg --log progress "${ITEM} (moving)"
    +		mv "${PRIOR_MYFILES_DIR}" "${ALLSKY_MYFILES_DIR}"
    +	else
    +		# Almost no one has this directory, so don't show to user.
    +		display_msg --logonly info "${ITEM}: ${NOT_RESTORED}"
    +	fi
     
    -		activate_python_venv
    +	ITEM="${SPACE}'config/ssl' directory"
    +	if [[ -d ${PRIOR_CONFIG_DIR}/ssl ]]; then
    +		display_msg --log progress "${ITEM} (copying)"
    +		cp -ar "${PRIOR_CONFIG_DIR}/ssl" "${ALLSKY_CONFIG}"
    +	else
    +		# Almost no one has this directory, so don't show to user.
    +		display_msg --logonly info "${ITEM}: ${NOT_RESTORED}"
    +	fi
    +
    +	ITEM="${SPACE}'config/modules' directory"
    +	if [[ -d ${PRIOR_CONFIG_DIR}/modules ]]; then
    +		display_msg --log progress "${ITEM} (merging)"
     
     		# Copy the user's prior data to the new file which may contain new fields.
    -		if ! python3 "${ALLSKY_SCRIPTS}"/flowupgrade.py --prior "${PRIOR_CONFIG_DIR}" --config "${ALLSKY_CONFIG}" ; then
    +		activate_python_venv
    +		if ! python3 "${ALLSKY_SCRIPTS}"/flowupgrade.py \
    +				--prior "${PRIOR_CONFIG_DIR}" --config "${ALLSKY_CONFIG}" ; then
     			display_msg --log error "Copying 'modules' directory had problems."
     		fi
     	else
     		display_msg --log progress "${ITEM}: ${NOT_RESTORED}"
     	fi
     
    -	ITEM="${SPACE}'overlay' directory"
    +	ITEM="${SPACE}'config/overlay' directory"
     	if [[ -d ${PRIOR_CONFIG_DIR}/overlay ]]; then
    -		display_msg --log progress "${ITEM}"
    -		cp -ar "${PRIOR_CONFIG_DIR}/overlay" "${ALLSKY_CONFIG}"
    -
    -		# Restore the fields.json file as it's part of the main Allsky distribution
    -		# and should be replaced during an upgrade.
    -		cp -ar "${ALLSKY_REPO}/overlay/config/fields.json" "${ALLSKY_OVERLAY}/config/"
    +		display_msg --log progress "${ITEM} (copying)"
    +# TODO: ALEX: FIX: Copying everying in these 3 directories means we can never release new versions.
    +		cp -a -r "${PRIOR_CONFIG_DIR}/overlay/fonts" "${ALLSKY_OVERLAY}"
    +		cp -a -r "${PRIOR_CONFIG_DIR}/overlay/images" "${ALLSKY_OVERLAY}"
    +		cp -a -r "${PRIOR_CONFIG_DIR}/overlay/imagethumbnails" "${ALLSKY_OVERLAY}"
    +
    +		cp -a    "${PRIOR_CONFIG_DIR}/overlay/config/userfields.json" "${ALLSKY_OVERLAY}/config"
    +		cp -a    "${PRIOR_CONFIG_DIR}/overlay/config/oe-config.json" "${ALLSKY_OVERLAY}/config"
     	else
     		display_msg --log progress "${ITEM}: ${NOT_RESTORED}"
     	fi
     
    -	ITEM="${SPACE}'ssl' directory"
    -	if [[ -d ${PRIOR_CONFIG_DIR}/ssl ]]; then
    -		display_msg --log progress "${ITEM}"
    -		cp -ar "${PRIOR_CONFIG_DIR}/ssl" "${ALLSKY_CONFIG}"
    -	else
    -		# Almost no one has this directory, so don't show to user.
    -		display_msg --logonly info "${ITEM}: ${NOT_RESTORED}"
    -	fi
    -
    -	# This is not in a "standard" directory so we need to determine where it was.
    -	local EXTRA="${PRIOR_ALLSKY_DIR}${ALLSKY_EXTRA//${ALLSKY_HOME}/}"
    -	ITEM="${SPACE}'${EXTRA}' directory"
    -	if [[ -d ${EXTRA} ]]; then
    -		display_msg --log progress "${ITEM}"
    -		cp -ar "${EXTRA}" "${ALLSKY_EXTRA}/.."
    +	X="${PRIOR_CONFIG_DIR}${MY_OVERLAY_TEMPLATES/${ALLSKY_CONFIG}/}"
    +	local Z="$( dirname "${MY_OVERLAY_TEMPLATES}" )"
    +	ITEM="${SPACE}'config/$( basename "${Z}" )/$( basename "${X}" )' directory"
    +	if [[ -d ${X} ]]; then
    +		display_msg --log progress "${ITEM} (copying)"
    +		cp -ar "${X}" "$( dirname "${MY_OVERLAY_TEMPLATES}" )"
     	else
     		display_msg --log progress "${ITEM}: ${NOT_RESTORED}"
     	fi
     
    -	local D
    -	if [[ ${PRIOR_ALLSKY} == "newStyle" ]]; then
    + 	# Globals: SENSOR_WIDTH, SENSOR_HEIGHT, FULL_OVERLAY_NAME, SHORT_OVERLAY_NAME, OVERLAY_NAME, PRIOR_CAMERA_TYPE
    +
    +	# PRIOR_OVERLAY_FILE is no longer used, but if it exists,
    +	# convert it to the new name/format.
    +	PRIOR_OVERLAY_FILE="${PRIOR_CONFIG_DIR}/overlay/config/overlay.json"
    +	PRIOR_OVERLAY_REPO_FILE="${PRIOR_ALLSKY_DIR}/config_repo/overlay/config/overlay-${PRIOR_CAMERA_TYPE}.json"
    +
    +	# If no prior overlay.json exists or the user never changed it (i.e., it's the same
    +	# as the prior confi_repo file), use the new format if its not been setup before.
    +
    +    local DAYTIME_OVERLAY="$( settings ".daytimeoverlay" "${PRIOR_SETTINGS_FILE}" )"
    +    local NIGHTTIME_OVERLAY="$( settings ".nighttimeoverlay" "${PRIOR_SETTINGS_FILE}" )"
    +
    +    if [[ -z "${DAYTIME_OVERLAY}" && -z "${NIGHTTIME_OVERLAY}" ]]; then
    +        ITEM="${SPACE}Overlay configuration file"
    +        if [[ ! -f ${PRIOR_OVERLAY_FILE} ]] ||
    +                cmp -s "${PRIOR_OVERLAY_FILE}" "${PRIOR_OVERLAY_REPO_FILE}" ; then
    +            MSG="${SPACE}User didn't change prior overlay file; using new '${OVERLAY_NAME}'"
    +            display_msg --logonly info "${MSG}"
    +        else
    +            # The user changed the old overlay file so copy it to the new format and
    +            # save its location in the settings file.
    +            # NOTE: we add a 1 to the overlay name here so that the overay manager can
    +            # pick it up and increment it as new overlays are created.
    +            OVERLAY_NAME="${FULL_OVERLAY_NAME/overlay/overlay1}"
    +            OVERLAY_NAME="${OVERLAY_NAME:-unknown.json}"
    +            display_msg --log progress "${ITEM} (renamed to '${OVERLAY_NAME}')"
    +
    +            DEST_FILE="${MY_OVERLAY_TEMPLATES}/${OVERLAY_NAME}"
    +
    +            # Add the metadata for the overlay manager
    +            # shellcheck disable=SC2086
    +            jq '. += {"metadata": {
    +                "camerabrand": "'${CAMERA_TYPE}'",
    +                "cameramodel": "'${CAMERA_MODEL}'",
    +                "cameraresolutionwidth": "'${SENSOR_WIDTH}'",
    +                "cameraresolutionheight": "'${SENSOR_HEIGHT}'",
    +                "tod": "both",
    +                "name": "'${CAMERA_TYPE}' '${CAMERA_MODEL}'"
    +            }}' "${PRIOR_OVERLAY_FILE}"  > "${DEST_FILE}"
    +        fi
    +
    +        for s in daytimeoverlay nighttimeoverlay
    +        do
    +            doV "" "OVERLAY_NAME" "${s}" "text" "${SETTINGS_FILE}"
    +        done
    +    else
    +		doV "" "DAYTIME_OVERLAY" "daytimeoverlay" "text" "${SETTINGS_FILE}"
    +		doV "" "NIGHTTIME_OVERLAY" "nighttimeoverlay" "text" "${SETTINGS_FILE}"
    +    fi
    +
    +	if [[ ${PRIOR_ALLSKY_STYLE} == "${NEW_STYLE_ALLSKY}" ]]; then
     		D="${PRIOR_CONFIG_DIR}"
     	else
     		# raspap.auth was in a different directory in older versions.
     		D="${OLD_RASPAP_DIR}"
     	fi
    -	ITEM="${SPACE}WebUI security settings"
    -	if [[ -f ${D}/raspap.auth ]]; then
    -		display_msg --log progress "${ITEM}"
    -		cp -a "${D}/raspap.auth" "${ALLSKY_CONFIG}"
    +	R="raspap.auth"
    +	ITEM="${SPACE}WebUI security settings (${R})"
    +	if [[ -f ${D}/${R} ]]; then
    +		display_msg --log progress "${ITEM} (copying)"
    +		cp -a "${D}/${R}" "${ALLSKY_CONFIG}"
     	else
     		display_msg --log progress "${ITEM}: ${NOT_RESTORED}"
     	fi
     
    -	# Restore any REMOTE Allsky Website configuration file.
    +	ITEM="${SPACE}uservariables.sh"
    +	if [[ -f ${PRIOR_CONFIG_DIR}/uservariables.sh ]]; then
    +		display_msg --log progress "${ITEM}: ${NOT_RESTORED} (copying)"
    +		cp -a "${PRIOR_CONFIG_DIR}/uservariables.sh" "${ALLSKY_CONFIG}"
    +	# Don't bother with the "else" part since this file is very rarely used.
    +	fi
    +
    +
    +	########## Website files
    +	# ALLSKY_ENV is for a remote Website and/or server.
    +	# Restore it now because it's potentially written to below.
    +	E="$( basename "${ALLSKY_ENV}" )"
    +	ITEM="${SPACE}'${E}' file"
    +	if [[ -f ${PRIOR_ALLSKY_DIR}/${E} ]]; then
    +		display_msg --log progress "${ITEM} (copying)"
    +		cp -ar "${PRIOR_ALLSKY_DIR}/${E}" "${ALLSKY_ENV}"
    +	fi
    +
    +	# Restore the remote Allsky Website configuration file if it exists.
     	ITEM="${SPACE}'${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_NAME}'"
    -	if [[ -f ${PRIOR_CONFIG_DIR}/${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_NAME} ]]; then
    -		display_msg --log progress "${ITEM}"
    -		cp -a "${PRIOR_CONFIG_DIR}/${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_NAME}" \
    -			"${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}"
    -
    -		# Used below to update "AllskyVersion" if needed.
    -		V="$( settings .config.AllskyVersion "${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}" )"
    -
    -		# Check if this is an older Allsky Website configuration file type.
    -		# The remote config file should have .ConfigVersion.
    -		local OLD="false"
    -		local NEW_CONFIG_VERSION="$(settings .ConfigVersion "${REPO_WEBCONFIG_FILE}")"
    -		local PRIOR_CONFIG_VERSION="$(settings .ConfigVersion "${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}")"
    -		if [[ -z ${PRIOR_CONFIG_VERSION} ]]; then
    -			OLD="true"		# Hmmm, it should have the version
    -			MSG="Prior Website configuration file '${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}'"
    -			MSG="${MSG}\nis missing .ConfigVersion.  It should be '${NEW_CONFIG_VERSION}'."
    -			display_msg --log warning "${MSG}"
    -			PRIOR_CONFIG_VERSION="** Unknown **"
    -		elif [[ ${PRIOR_CONFIG_VERSION} < "${NEW_CONFIG_VERSION}" ]]; then
    -			OLD="true"
    +	if [[ -f ${PRIOR_REMOTE_WEBSITE_CONFIGURATION_FILE} ]]; then
    +		display_msg --log progress "${ITEM} (copying)"
    +		cp "${PRIOR_REMOTE_WEBSITE_CONFIGURATION_FILE}" "${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}"
    +
    +		# Check the Allsky version in the remote file - if it's old let user know.
    +		PRIOR_V="$( settings ".${WEBSITE_ALLSKY_VERSION}" "${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}" )"
    +# TODO: if not using remote Website, change messages below.
    +		if [[ ${PRIOR_V} == "${ALLSKY_VERSION}" ]]; then
    +			display_msg --log progress "Remote Website already at latest Allsky version ${PRIOR_V}."
    +		else
    +			MSG="Your remote Website needs to be updated to this newest version."
    +			MSG+="\nIt is at version ${PRIOR_V}"
    +			# This command will update the version.
    +			MSG+="\n\nRun:  cd ~/allsky;  ./remoteWebsiteInstall.sh"
    +			display_msg --log notice "${MSG}"
     		fi
    +	else
    +		display_msg --log progress "${ITEM}: ${NOT_RESTORED}"
     
    -		if [[ ${OLD} == "true" ]]; then
    -			MSG="Your ${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE} is an older version.\n"
    -			MSG="${MSG}Your    version: ${PRIOR_CONFIG_VERSION}\n"
    -			MSG="${MSG}Current version: ${NEW_CONFIG_VERSION}\n"
    -			MSG="${MSG}\nPlease compare it to the new one in ${REPO_WEBCONFIG_FILE}"
    -			MSG="${MSG} to see what fields have been added, changed, or removed.\n"
    -			display_msg --log warning "${MSG}"
    -			echo -e "\n\n==========\n${MSG}" >> "${POST_INSTALLATION_ACTIONS}"
    -		else
    -			MSG="${SPACE}${SPACE}Remote Website .ConfigVersion is current @ ${NEW_CONFIG_VERSION}"
    -			display_msg --logonly info "${MSG}"
    +		# Create a default file.
    +		prepare_local_website ""
    +	fi
    +
    +	# Do NOT restore options.json - it will be recreated.
    +
    +	# Done with restores, now the updates.
    +
    +	COPIED_PRIOR_CONFIG_SH="true"		# Global variable
    +	if [[ -s ${PRIOR_CONFIG_FILE} ]]; then
    +		# This copies the settings from the prior config file to the settings file.
    +		convert_config_sh "${PRIOR_CONFIG_FILE}" "${SETTINGS_FILE}" "install" ||
    +			COPIED_PRIOR_CONFIG_SH="false"
    +	fi
    +	STATUS_VARIABLES+=( "COPIED_PRIOR_CONFIG_SH='${COPIED_PRIOR_CONFIG_SH}'\n" )
    +
    +	# The ftp-settings.sh file was originally in allsky/scripts but
    +	# moved to allsky/config in version ${FIRST_VERSION_VERSION}.
    +	# It no longer exists, but if a prior one exists copy its contents to the settings file.
    +	# Get the current and prior (if any) file version.
    +	if [[ -f ${PRIOR_FTP_FILE} ]]; then			# allsky/config version
    +		# Version ${FIRST_VERSION_VERSION} and newer.
    +		:
    +	elif [[ -f ${PRIOR_ALLSKY_DIR}/scripts/ftp-settings.sh ]]; then
    +		# pre ${FIRST_VERSION_VERSION}
    +		PRIOR_FTP_FILE="${PRIOR_ALLSKY_DIR}/scripts/ftp-settings.sh"
    +	else
    +		if [[ -s ${PRIOR_CONFIG_FILE} ]]; then
    +			# If there was a prior config file there should have been a prior ftp file.
    +			display_msg --log error "Unable to find prior ftp-settings.sh (${PRIOR_FTP_FILE})."
     		fi
    +		PRIOR_FTP_FILE=""
    +	fi
    +	COPIED_PRIOR_FTP_SH="true"			# Global variable
    +	if [[ -s ${PRIOR_FTP_FILE} ]]; then
    +		convert_ftp_sh "${PRIOR_FTP_FILE}" "${SETTINGS_FILE}" "install" ||
    +			COPIED_PRIOR_FTP_SH="false"
    +	fi
    +	STATUS_VARIABLES+=( "COPIED_PRIOR_FTP_SH='${COPIED_PRIOR_FTP_SH}'\n" )
    +
    +
    +	if [[ ${COPIED_PRIOR_CONFIG_SH} == "true" && ${COPIED_PRIOR_FTP_SH} == "true" ]]; then
    +		return 0
    +	fi
    +
    +	MSG="You need to manually move the CONTENTS of:"
    +	if [[ ${COPIED_PRIOR_CONFIG_SH} == "false" ]]; then
    +		MSG="${MSG}\n     ${PRIOR_CONFIG_DIR}/config.sh"
    +	fi
    +	if [[ ${COPIED_PRIOR_FTP_SH} == "false" ]]; then
    +		MSG="${MSG}\n     ${PRIOR_FTP_FILE}"
    +	fi
    +	MSG+=""
    +	whiptail --title "${TITLE}" --msgbox "${MSG}${MSGb}" 20 "${WT_WIDTH}" 3>&1 1>&2 2>&3
    +
    +	display_msg --log info "\n${MSG}${MSGb}\n"
    +	echo -e "\n\n========== ACTION NEEDED:\n${MSG}" >> "${POST_INSTALLATION_ACTIONS}"
    +	if [[ -n ${MSG2} ]]; then
    +		display_msg --log info "\n${MSG2}\n"
    +		echo -e "\n${MSG2}" >> "${POST_INSTALLATION_ACTIONS}"
    +	fi
    +
    +	return 0
    +}
    +
    +
    +####
    +# If a prior local Website exists move its data to the new location.
    +# If using a remote website, copy it's config file.
    +restore_prior_website_files()
    +{
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
    +	local ITEM  D  count  A  MSG
    +
    +	if [[ ! -f ${ALLSKY_ENV} ]]; then
    +		display_msg --log progress "${SPACE}$( basename "${ALLSKY_ENV}" ) (creating)"
    +		cp "${REPO_ENV_FILE}" "${ALLSKY_ENV}"
    +	fi
    +
    +	ITEM="${SPACE}Local Website files"
    +	if [[ -z ${PRIOR_WEBSITE_DIR} ]]; then
    +		display_msg --log progress "${ITEM}: ${NOT_RESTORED}"
    +
    +		# Create a default configuration file in case they decide to use a local Website.
    +		prepare_local_website ""
    +		return
    +	fi
    +
    +	display_msg --log progress "${ITEM}:"
    +
    +	# Each data directory will have zero or more images/videos.
    +	# Make sure we do NOT mv any .php files.
    +
    +	ITEM="${SPACE}${SPACE}timelapse videos"
    +	D="${PRIOR_WEBSITE_DIR}/videos/thumbnails"
    +	[[ -d ${D} ]] && mv "${D}"   "${ALLSKY_WEBSITE}/videos"
    +	count=$( get_count "${PRIOR_WEBSITE_DIR}/videos" 'allsky-*' )
    +	if [[ ${count} -ge 1 ]]; then
    +		display_msg --log progress "${ITEM} (moving)"
    +		mv "${PRIOR_WEBSITE_DIR}"/videos/allsky-*   "${ALLSKY_WEBSITE}/videos"
     	else
    -		# We don't check for old LOCAL Allsky Website configuration files.
    -		# That's done when they install the Allsky Website.
     		display_msg --log progress "${ITEM}: ${NOT_RESTORED}"
     	fi
     
    -	ITEM="${SPACE}uservariables.sh"
    -	if [[ -f ${PRIOR_CONFIG_DIR}/uservariables.sh ]]; then
    +	ITEM="${SPACE}${SPACE}keograms"
    +	D="${PRIOR_WEBSITE_DIR}/keograms/thumbnails"
    +	[[ -d ${D} ]] && mv "${D}"   "${ALLSKY_WEBSITE}/keograms"
    +	count=$( get_count "${PRIOR_WEBSITE_DIR}/keograms" 'keogram-*' )
    +	if [[ ${count} -ge 1 ]]; then
    +		display_msg --log progress "${ITEM} (moving)"
    +		mv "${PRIOR_WEBSITE_DIR}"/keograms/keogram-*   "${ALLSKY_WEBSITE}/keograms"
    +	else
     		display_msg --log progress "${ITEM}: ${NOT_RESTORED}"
    -		cp -a "${PRIOR_CONFIG_DIR}/uservariables.sh" "${ALLSKY_CONFIG}"
    -	# Don't bother with the "else" part since this file is very rarely used.
     	fi
     
    -	restore_prior_settings_file
    +	ITEM="${SPACE}${SPACE}startrails"
    +	D="${PRIOR_WEBSITE_DIR}/startrails/thumbnails"
    +	[[ -d ${D} ]] && mv "${D}"   "${ALLSKY_WEBSITE}/startrails"
    +	count=$( get_count "${PRIOR_WEBSITE_DIR}/startrails" 'startrails-*' )
    +	if [[ ${count} -ge 1 ]]; then
    +		display_msg --log progress "${ITEM} (moving)"
    +		mv "${PRIOR_WEBSITE_DIR}"/startrails/startrails-*   "${ALLSKY_WEBSITE}/startrails"
    +	else
    +		display_msg --log progress "${ITEM}: ${NOT_RESTORED}"
    +	fi
     
    -	# Do NOT restore options.json - it will be recreated.
    +	ITEM="${SPACE}${SPACE}'${ALLSKY_MYFILES_NAME}' directory"
    +	D="${PRIOR_WEBSITE_DIR}/${ALLSKY_MYFILES_NAME}"
    +	if [[ -d ${D} ]]; then
    +		display_msg --log progress "${ITEM} (moving)"
    +		mv "${D}"   "${ALLSKY_WEBSITE_MYFILES_DIR}"
    +	else
    +		display_msg --logonly info "${ITEM}: ${NOT_RESTORED}"
    +	fi
    +
    +	# This is the old name.
    +# TODO: remove this check in the next release.
    +	ITEM="${SPACE}${SPACE}'myImages' directory"
    +	D="${PRIOR_WEBSITE_DIR}/myImages"
    +	if [[ -d ${D} ]]; then
    +		count=$( get_count "${D}" '*' )
    +		if [[ ${count} -gt 1 ]]; then
    +			local MSG2="  Please use '${ALLSKY_WEBSITE_MYFILES_DIR}' going forward."
    +			display_msg --log progress "${ITEM} (copying to '${ALLSKY_WEBSITE_MYFILES_DIR}')" "${MSG2}"
    +			# TODO: This won't copy dot files.
    +			cp "${D}"/*   "${ALLSKY_WEBSITE_MYFILES_DIR}"
    +		fi
    +	else
    +		# Since this is obsolete only add to log file.
    +		display_msg --logonly progress "${ITEM}: ${NOT_RESTORED}"
    +	fi
     
    -	# See if the prior config.sh and ftp-setting.sh are the same version as
    -	# the new ones; if so, we can copy them to the new version.
    -	# Currently what's in ${ALLSKY_CONFIG} are copies of the repo files.
    -	RESTORED_PRIOR_CONFIG_SH="false"		# Global variable
    -	RESTORED_PRIOR_FTP_SH="false"			# Global variable
    -
    -	local CONFIG_SH_VERSION="$( get_variable "CONFIG_SH_VERSION" "${ALLSKY_CONFIG}/config.sh" )"
    -	local PRIOR_CONFIG_SH_VERSION="$( get_variable "CONFIG_SH_VERSION" "${PRIOR_CONFIG_FILE}" )"
    -	ITEM="${SPACE}'config.sh' file"
    -	if [[ ${CONFIG_SH_VERSION} == "${PRIOR_CONFIG_SH_VERSION}" ]]; then
    -		display_msg --log progress "${ITEM}, as is"
    -		cp "${PRIOR_CONFIG_FILE}" "${ALLSKY_CONFIG}" && RESTORED_PRIOR_CONFIG_SH="true"
    +	A="data.json"
    +	ITEM="${SPACE}${SPACE}${A}"
    +	D="${PRIOR_WEBSITE_DIR}/${A}"
    +	if [[ -f ${D} ]]; then
    +		if ! cmp --silent "${D}" "${ALLSKY_WEBSITE}/${A}" ; then
    +			display_msg --log progress "${ITEM} (copying)"
    +			cp "${D}" "${ALLSKY_WEBSITE}"
    +		fi
    +	else
    +		display_msg --log progress "${ITEM}: ${NOT_RESTORED}"
    +	fi
    +
    +	A="analyticsTracking.js"
    +	ITEM="${SPACE}${SPACE}${A}"
    +	D="${PRIOR_WEBSITE_DIR}/${A}"
    +	if [[ -f ${D} ]]; then
    +		if ! cmp --silent "${D}" "${ALLSKY_WEBSITE}/${A}" ; then
    +			display_msg --log progress "${ITEM} (copying)"
    +			cp "${D}" "${ALLSKY_WEBSITE}"
    +		fi
     	else
    -		if [[ -z ${PRIOR_CONFIG_SH_VERSION} ]]; then
    -			MSG="no prior version specified"
    +		display_msg --log progress "${ITEM}: ${NOT_RESTORED}"
    +	fi
    +
    +	# Now deal with the local Website configuration file.
    +	if [[ ${PRIOR_WEBSITE_STYLE} == "${OLD_STYLE_ALLSKY}" ]]; then
    +		# The format of the old files is too different from the new file,
    +		# so force them to manually copy settings.
    +		MSG="You need to manually copy your prior Website settings in"
    +		MSG+="\n\t${PRIOR_WEBSITE_DIR}/config.js"
    +		MSG+="\nto '${ALLSKY_WEBSITE_CONFIGURATION_NAME}' in the"
    +		MSG+=" WebUI's 'Editor' page."
    +		display_msg --log info "${MSG}"
    +		{
    +			echo -e "\n\n========== ACTION NEEDED:"
    +			echo -e "${MSG}"
    +			echo "When done, check in '${PRIOR_WEBSITE_DIR}' for any files"
    +			echo "you may have added; if there are any, store them in"
    +			echo -e "\n   ${ALLSKY_WEBSITE_MYFILES_DIR}"
    +			echo "then remove the old website:  sudo rm -fr ${PRIOR_WEBSITE_DIR}"
    +		} >> "${POST_INSTALLATION_ACTIONS}"
    +
    +		# Create a default file.
    +		prepare_local_website ""
    +
    +	else		# NEW_STYLE_WEBSITE
    +		ITEM="${SPACE}${SPACE}${ALLSKY_WEBSITE_CONFIGURATION_NAME}"
    +		if [[ ${PRIOR_WEB_CONFIG_VERSION} < "${NEW_WEB_CONFIG_VERSION}" ]]; then
    +			MSG="${ITEM} (copying and updating for version ${NEW_WEB_CONFIG_VERSION})"
    +			display_msg --log progress "${MSG}"
    +		fi
    +
    +		# Copy the old file to the current location.
    +		cp "${PRIOR_WEBSITE_DIR}/${ALLSKY_WEBSITE_CONFIGURATION_NAME}" \
    +			"${ALLSKY_WEBSITE_CONFIGURATION_FILE}"
    +
    +		if [[ ${PRIOR_WEB_CONFIG_VERSION} < "${NEW_WEB_CONFIG_VERSION}" ]]; then
    +			# If different versions, then update the current one.
    +			update_old_website_config_file "${ALLSKY_WEBSITE_CONFIGURATION_FILE}" \
    +				"${PRIOR_WEB_CONFIG_VERSION}" "${NEW_WEB_CONFIG_VERSION}"
     		else
    -			# v2023.05.01 is hopefully the last version with config.sh so don't
    -			# bother writing a function to convert from the prior version to this.
    -			MSG="prior version is old (${PRIOR_CONFIG_SH_VERSION})"
    +			display_msg --log progress "${ITEM} (copying)"
    +			MSG="${SPACE}${SPACE}${SPACE}Already current @ version ${NEW_WEB_CONFIG_VERSION}"
    +			display_msg --logonly info "${MSG}"
     		fi
    -		display_msg --log progress "${ITEM}: ${NOT_RESTORED}: ${MSG}"
     	fi
     
    -	# Unlike the config.sh file which was always in allsky/config,
    -	# the ftp-settings.sh file used to be in allsky/scripts.
    -	# Get the current and prior (if any) file version.
    -	local FTP_SH_VERSION="$( get_variable "FTP_SH_VERSION" "${ALLSKY_CONFIG}/ftp-settings.sh" )"
    -	local PRIOR_FTP_SH_VERSION
    -	if [[ -f ${PRIOR_FTP_FILE} ]]; then
    -		# Allsky v2022.03.01 and newer.  v2022.03.01 doesn't have FTP_SH_VERSION.
    -		PRIOR_FTP_SH_VERSION="$( get_variable "FTP_SH_VERSION" "${PRIOR_FTP_FILE}" )"
    -		PRIOR_FTP_SH_VERSION="${PRIOR_FTP_SH_VERSION:-"no version"}"
    -	elif [[ -f ${PRIOR_ALLSKY_DIR}/scripts/ftp-settings.sh ]]; then
    -		# pre v2022.03.01
    -		PRIOR_FTP_FILE="${PRIOR_ALLSKY_DIR}/scripts/ftp-settings.sh"
    -		PRIOR_FTP_SH_VERSION="old"
    +	STATUS_VARIABLES+=( "${FUNCNAME[0]}='true'\n" )
    +}
    +
    +
    +####
    +# Restore the prior version of Allsky, but save this version.
    +# Anything moved from the prior version to this version is moved back.
    +RENAMED_DIR=""
    +
    +do_restore()
    +{
    +	local MSG  MSG2  ITEM  OK
    +
    +	# This is what the current ${ALLSKY_HOME} will be renamed to.
    +	RENAMED_DIR="${ALLSKY_HOME}-${ALLSKY_VERSION}"
    +
    +	MSG="Unable to restore Allsky - "
    +
    +	OK="true"
    +	if [[ ${USE_PRIOR_ALLSKY} == "false" ]]; then
    +		MSG+="No valid prior Allsky to restore."
    +		OK="false"
    +	fi
    +
    +	if [[ -d ${RENAMED_DIR} ]]; then
    +		MSG+="'${RENAMED_DIR}' already exists."
    +		MSG+="\nDid you already restore Allsky?"
    +		OK="false"
    +	fi
    +
    +	if [[ ! -d ${ALLSKY_CONFIG} ]]; then
    +		MSG+="Allsky isn't installed."
    +		OK="false"
    +	fi
    +	if [[ ! -d ${PRIOR_ALLSKY_DIR} ]]; then
    +		MSG+="no prior version exists in '${PRIOR_ALLSKY_DIR}'."
    +		OK="false"
    +	fi
    +	if [[ -d ${RENAMED_DIR} ]]; then
    +		MSG+="a restored version already exists in '${RENAMED_DIR}'."
    +		OK="false"
    +	fi
    +	if [[ ${OK} == "false" ]]; then
    +		display_msg --log error "${MSG}"
    +		exit_installation 1 "${STATUS_ERROR}" "${MSG}"
    +	fi
    +
    +	do_initial_heading
    +
    +	stop_Allsky
    +
    +	# During installation some files were MOVED from the old release to
    +	# the new release (which is now the current release).
    +	# Move those items back.
    +	# Don't worry about the items that were COPIED to the current release.
    +
    +	display_msg --log progress "Restoring files:"
    +
    +	ITEM="${SPACE}'images' directory"
    +	if [[ -d ${ALLSKY_HOME}/images ]]; then
    +		display_msg --log progress "${ITEM} (moving back)"
    +		mv "${ALLSKY_HOME}/images" "${PRIOR_ALLSKY_DIR}"
     	else
    -		display_msg --log error "Unable to find prior ftp-settings.sh"
    -		PRIOR_FTP_FILE=""
    -		PRIOR_FTP_SH_VERSION="no file"
    +		# This is probably very rare so let the user know
    +		display_msg --log progress "${ITEM}: ${NOT_RESTORED}.  This is unusual."
    +	fi
    +
    +	ITEM="${SPACE}'darks' directory"
    +	if [[ -d ${ALLSKY_HOME}/darks ]]; then
    +		display_msg --log progress "${ITEM} (moving back)"
    +		mv "${ALLSKY_HOME}/darks" "${PRIOR_ALLSKY_DIR}"
    +	else
    +		display_msg --log progress "${ITEM}: ${NOT_RESTORED}"
     	fi
     
    -	ITEM="${SPACE}'ftp-settings.sh'"
    -	if [[ ${FTP_SH_VERSION} == "${PRIOR_FTP_SH_VERSION}" ]]; then
    -		display_msg --log progress "${ITEM}, as is"
    -		cp "${PRIOR_FTP_FILE}" "${ALLSKY_CONFIG}" && RESTORED_PRIOR_FTP_SH="true"
    +	ITEM="${SPACE}'$( basename "$( dirname "${ALLSKY_MYFILES_DIR}" )" )/${ALLSKY_MYFILES_NAME}' directory"
    +	if [[ -d ${ALLSKY_MYFILES_DIR} ]]; then
    +		display_msg --log progress "${ITEM} (moving back)"
    +		mv "${ALLSKY_MYFILES_DIR}" "${PRIOR_MYFILES_DIR}"
     	else
    -		if [[ ${PRIOR_FTP_SH_VERSION} == "no version" ]]; then
    -			MSG=": unknown prior FTP_SH_VERSION"
    -		elif [[ ${PRIOR_FTP_SH_VERSION} == "old" ]]; then
    -			MSG=": old location so no FTP_SH_VERSION"
    -		elif [[ ${PRIOR_FTP_SH_VERSION} != "no file" ]]; then
    -			MSG=": unknown PRIOR_FTP_SH_VERSION: '${PRIOR_FTP_SH_VERSION}'"
    -		fi
    -		display_msg --log progress "${ITEM}: ${NOT_RESTORED}${MSG}"
    +		# Few people have this directory, so don't show to user.
    +		display_msg --logonly info "${ITEM}: ${NOT_RESTORED}"
     	fi
     
    -	# Done with restores, now the updates.
    +	if [[ -n ${PRIOR_WEBSITE_DIR} ]]; then
    +		display_msg --log progress "${SPACE}Local Website files:"
    +
    +		ITEM="${SPACE}${SPACE}timelapse videos"
    +		D="${ALLSKY_WEBSITE}/videos/thumbnails"
    +		[[ -d ${D} ]] && mv "${D}"   "${PRIOR_WEBSITE_DIR}/videos"
    +		count=$( get_count "${ALLSKY_WEBSITE}/videos" 'allsky-*' )
    +		if [[ ${count} -ge 1 ]]; then
    +			display_msg --log progress "${ITEM} (moving back)"
    +			mv "${ALLSKY_WEBSITE}"/videos/allsky-*   "${PRIOR_WEBSITE_DIR}/videos"
    +		else
    +			display_msg --log progress "${ITEM}: ${NOT_RESTORED}"
    +		fi
    +
    +		ITEM="${SPACE}${SPACE}keograms"
    +		D="${ALLSKY_WEBSITE}/keograms/thumbnails"
    +		[[ -d ${D} ]] && mv "${D}"   "${PRIOR_WEBSITE_DIR}/keograms"
    +		count=$( get_count "${ALLSKY_WEBSITE}/keograms" 'keogram-*' )
    +		if [[ ${count} -ge 1 ]]; then
    +			display_msg --log progress "${ITEM} (moving back)"
    +			mv "${ALLSKY_WEBSITE}"/keograms/keogram-*   "${PRIOR_WEBSITE_DIR}/keograms"
    +		else
    +			display_msg --log progress "${ITEM}: ${NOT_RESTORED}"
    +		fi
     
    -	if [[ -f ${PRIOR_CONFIG_DIR}/${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_NAME} ]]; then
    -		if [[ ${V} != "${ALLSKY_VERSION}" ]]; then
    -			MSG="Updating AllskyVersion in remote Website from '${V}' to '${ALLSKY_VERSION}'"
    -			display_msg --log progress "${MSG}"
    -			update_json_file ".config.AllskyVersion" "${ALLSKY_VERSION}" \
    -				"${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}"
    +		ITEM="${SPACE}${SPACE}startrails"
    +		D="${ALLSKY_WEBSITE}/startrails/thumbnails"
    +		[[ -d ${D} ]] && mv "${D}"   "${PRIOR_WEBSITE_DIR}/startrails"
    +		count=$( get_count "${ALLSKY_WEBSITE}/startrails" 'startrails-*' )
    +		if [[ ${count} -ge 1 ]]; then
    +			display_msg --log progress "${ITEM} (moving back)"
    +			mv "${ALLSKY_WEBSITE}"/startrails/startrails-*   "${PRIOR_WEBSITE_DIR}/startrails"
     		else
    -			display_msg --log progress "Prior remote Website already at latest Allsky version ${V}."
    +			display_msg --log progress "${ITEM}: ${NOT_RESTORED}"
     		fi
    -	fi
     
    -	if [[ ${CONFIG_SH_VERSION} == "${PRIOR_CONFIG_SH_VERSION}" ]]; then
    -		# This version should be the same as the what's in the prior "version" file.
    -		local PRIOR="$( get_variable "ALLSKY_VERSION" "${PRIOR_CONFIG_FILE}" )"
    -		if [[ ${PRIOR} != "${ALLSKY_VERSION}" ]]; then
    -			MSG="Updating ALLSKY_VERSION in 'config.sh' to '${ALLSKY_VERSION}'."
    -			sed -i "/ALLSKY_VERSION=/ c ALLSKY_VERSION=\"${ALLSKY_VERSION}\"" "${PRIOR_CONFIG_FILE}"
    -			display_msg --log progress "${MSG}"
    +		ITEM="${SPACE}${SPACE}${ALLSKY_MYFILES_NAME}"
    +		if [[ -d ${ALLSKY_WEBSITE_MYFILES_DIR} ]]; then
    +			display_msg --log progress "${ITEM} (moving)"
    +			mv "${ALLSKY_WEBSITE_MYFILES_DIR}"   "${PRIOR_WEBSITE_DIR}"
     		else
    -			MSG="ALLSKY_VERSION (${PRIOR}) in prior config.sh same as new version."
    -			display_msg --logonly info "${MSG}"
    +			display_msg --logonly info "${ITEM}: ${NOT_RESTORED}"
     		fi
     	fi
     
    -	STATUS_VARIABLES+=( "RESTORED_PRIOR_CONFIG_SH='${RESTORED_PRIOR_CONFIG_SH}'\n" )
    -	STATUS_VARIABLES+=( "RESTORED_PRIOR_FTP_SH='${RESTORED_PRIOR_FTP_SH}'\n" )
    +	# Since we'll be running a new Allsky, start off with clean log files.
    +	create_lighttpd_log_file
    +	create_allsky_logs "false"		# "false" = only create log file
     
    -	if [[ ${RESTORED_PRIOR_CONFIG_SH} == "true" && ${RESTORED_PRIOR_FTP_SH} == "true" ]]; then
    -		return 0
    +	# If ${ALLSKY_TMP} is a memory filesystem, unmount it.
    +	if is_mounted "${ALLSKY_TMP}" ; then
    +		display_msg --log progress "Unmounting '${ALLSKY_TMP}'."
    +		umount_tmp "${ALLSKY_TMP}"
    +		WAS_MOUNTED="true"
    +	else
    +		WAS_MOUNTED="false"
     	fi
     
    -	if [[ ${PRIOR_ALLSKY} == "newStyle" ]]; then
    -		# The prior versions are similar to the new ones.
    -		MSG=""
    -		# If it has a version number it's probably close to the current version.
    -		if [[ ${RESTORED_PRIOR_CONFIG_SH} == "false" && -n ${PRIOR_CONFIG_SH_VERSION} ]]; then
    -			MSG="${MSG}\nYour prior 'config.sh' file is similar to the new one."
    -		fi
    -		if [[ ${RESTORED_PRIOR_FTP_SH} == "false" && ${PRIOR_FTP_SH_VERSION} == "no version" ]]; then
    -			MSG="${MSG}\nYour prior 'ftp-settings.sh' file is similar to the new one."
    -		fi
    -		# Don't wantn this line in the post-installation file.
    -		MSGb="\nAfter installation, see ${POST_INSTALLATION_ACTIONS} for details."
    -
    -		MSG2="You can compare the old and new configuration files using the following commands,"
    -		MSG2="${MSG2}\nand apply your changes from the prior file to the new file."
    -		MSG2="${MSG2}\nDo NOT simply copy the old files to the new location because"
    -		MSG2="${MSG2}\ntheir formats are different."
    -		MSG2="${MSG2}\n\ndiff ${PRIOR_CONFIG_DIR}/config.sh ${ALLSKY_CONFIG}"
    -		MSG2="${MSG2}\n\n   and"
    -		MSG2="${MSG2}\n\ndiff ${PRIOR_FTP_FILE} ${ALLSKY_CONFIG}"
    -	else
    -		MSG="You need to manually move the CONTENTS of:"
    -		if [[ ${RESTORED_PRIOR_CONFIG_SH} == "false" ]]; then
    -			MSG="${MSG}\n     ${PRIOR_CONFIG_DIR}/config.sh"
    -		fi
    -		if [[ ${RESTORED_PRIOR_FTP_SH} == "false" ]]; then
    -			MSG="${MSG}\n     ${PRIOR_FTP_FILE}"
    -		fi
    -		MSG="${MSG}\n\nto the new files in ${ALLSKY_CONFIG}."
    -		MSG="${MSG}\n\nNOTE: some settings are no longer in the new files and some changed names"
    -		MSG="${MSG}\nso NOT add the old/deleted settings back in or simply copy the files."
    -		MSG="${MSG}\n*** This will take several minutes ***"
    -		MSGb=""
    -		MSG2=""
    +	display_msg --log progress "Renaming '${ALLSKY_HOME}' to '${RENAMED_DIR}'"
    +	if ! mv "${ALLSKY_HOME}" "${RENAMED_DIR}" ; then
    +		MSG="Unable to rename '${ALLSKY_HOME}' to '${RENAMED_DIR}'"
    +		exit_installation 1 "${STATUS_ERROR}" "${MSG}"
     	fi
    -	MSG="${MSG}"
    -	whiptail --title "${TITLE}" --msgbox "${MSG}${MSGb}" 20 "${WT_WIDTH}" 3>&1 1>&2 2>&3
     
    -	display_msg --log info "\n${MSG}${MSGb}\n"
    -	echo -e "\n\n==========\n${MSG}" >> "${POST_INSTALLATION_ACTIONS}"
    -	if [[ -n ${MSG2} ]]; then
    -		display_msg --log info "\n${MSG2}\n"
    -		echo -e "\n${MSG2}" >> "${POST_INSTALLATION_ACTIONS}"
    +	# Need to point to location of original Allsky.
    +	DISPLAY_MSG_LOG="${DISPLAY_MSG_LOG/${ALLSKY_HOME}/${RENAMED_DIR}}"
    +	STATUS_FILE="${STATUS_FILE/${ALLSKY_HOME}/${RENAMED_DIR}}"
    +	ALLSKY_SCRIPTS="${ALLSKY_SCRIPTS/${ALLSKY_HOME}/${RENAMED_DIR}}"
    +
    +	display_msg --log progress "Renaming '${PRIOR_ALLSKY_DIR}' to '${ALLSKY_HOME}'"
    +	if ! mv "${PRIOR_ALLSKY_DIR}" "${ALLSKY_HOME}" ; then
    +		MSG="Unable to rename '${PRIOR_ALLSKY_DIR}' to '${ALLSKY_HOME}'"
    +		exit_installation 1 "${STATUS_ERROR}" "${MSG}"
     	fi
    +
    +	if [[ ${WAS_MOUNTED} == "true" ]]; then
    +		# Remounts ${ALLSKY_TMP}
    +		display_msg --log progress "Re-mounting '${ALLSKY_TMP}'."
    +		sudo mount -a
    +	fi
    +
    +	mkdir -p "$( dirname "${POST_INSTALLATION_ACTIONS}" )"
    +
    +	MSG="\nRestoration is done and"
    +	MSG2=" Allsky needs its settings checked."
    +	display_msg --log progress "${MSG}" "${MSG2}"
    +	echo -e "\n\n========== ACTION NEEDED:\n${MSG}${MSG2}" >> "${POST_INSTALLATION_ACTIONS}"
    +
    +	MSG="Restoration is done.  Go to the 'Allsky Settings' page of the WebUI and"
    +	MSG+="\nmake any necessary changes, then press the 'Save changes' button."
    +	echo -e "${MSG}" >> "${POST_INSTALLATION_ACTIONS}"
    +
    +	whiptail --title "${TITLE}" --msgbox "${MSG}" 12 "${WT_WIDTH}" 3>&1 1>&2 2>&3
    +	display_image "ConfigurationNeeded"
    +
    +	# Force the user to look at the settings before Allsky will run.
    +	update_json_file -d ".lastchanged" "" "${SETTINGS_FILE}"
    +
    +	exit_installation 0 "${STATUS_OK}" ""
     }
     
    +####
    +# "Fix" things then exit.
    +# This can be needed if the user hosed something up, or there was a problem somewhere.
    +# It does no harm to call this when not needed.
    +do_fix()
    +{
    +	update_php_defines
    +	set_permissions
    +	exit 0
    +}
     
     ####
     # Update Allsky and exit.  It basically resets things.
     # This can be needed if the user hosed something up, or there was a problem somewhere.
     do_update()
     {
    -	#shellcheck disable=SC1091		# file doesn't exist in GitHub
    -	source "${ALLSKY_CONFIG}/config.sh" || exit "${ALLSKY_ERROR_STOP}"	# Get current CAMERA_TYPE
    +	CAMERA_TYPE="$( settings ".cameratype" )"
     	if [[ -z ${CAMERA_TYPE} ]]; then
    -		display_msg --log error "CAMERA_TYPE not set in config.sh."
    -		exit_installation 1 "${STATUS_ERROR}" "No CAMERA_TYPE in config.sh during update."
    +		display_msg --log error "Camera Type not set in settings file."
    +		exit_installation 1 "${STATUS_ERROR}" "No Camera Type in settings file during update."
     	fi
     
    -	[[ ${create_webui_defines} != "true" ]] && create_webui_defines
    -
    -	save_camera_capabilities "false" || exit 1
    -	set_permissions
    +	save_camera_capabilities "false"
    +	do_fix
     
    -	# Update the sudoers file if it's missing some entries.
    -	# Look for the last entry added (should be the last entry in the file).
    -	# Don't simply copy the repo file to the final location in case the repo file isn't up to date.
    -	if ! grep --silent "/date" "${FINAL_SUDOERS_FILE}" ; then
    -		display_msg --log progress "Updating sudoers list."
    -		if ! grep --silent "/date" "${REPO_SUDOERS_FILE}" ; then
    -			local F="$( basename "${REPO_SUDOERS_FILE}" )"
    -			MSG="Please get the newest '${F}' file from Git and try again."
    -			display_msg --log error "${MSG}"
    -			exit_installation 2 "${STATUS_ERROR}" "'${F}' file is old."
    -		fi
    -		do_sudoers
    -	fi
    +	do_allsky_status "ALLSKY_STATUS_NOT_RUNNING"
     
     	exit_installation 0 "${STATUS_OK}" "Update completed."
     }
     
    -
     ####
    -# Install the overlay and modules system
    -install_overlay()
    +# Install the Truetype fonts
    +install_fonts()
     {
    -	if [[ ${installed_PHP_modules} != "true" ]]; then
    -		display_msg --log progress "Installing PHP modules and dependencies."
    -		TMP="${ALLSKY_INSTALLATION_LOGS}/PHP_modules.log"
    -		sudo apt-get --assume-yes install php-zip php-sqlite3 python3-pip > "${TMP}" 2>&1
    -		check_success $? "PHP module installation failed" "${TMP}" "${DEBUG}"
    -		[[ $? -ne 0 ]] && exit_with_image 1 "${STATUS_ERROR}" "PHP module install failed."
    +	declare -n v="${FUNCNAME[0]}"
    +	if [[ ${v} == "true" ]]; then
    +		display_msg --logonly info "Fonts already installed"
    +		return
    +	fi
    +	[[ ${SKIP} == "true" ]] && return
    +
    +	display_msg --log progress "Installing Truetype fonts."
    +	TMP="${ALLSKY_LOGS}/msttcorefonts.log"
    +	local M="Truetype fonts failed"
    +	run_aptGet msttcorefonts > "${TMP}" 2>&1
    +	check_success $? "${M}" "${TMP}" "${DEBUG}" || exit_with_image 1 "${STATUS_ERROR}" "${M}"
     
    -		TMP="${ALLSKY_INSTALLATION_LOGS}/libatlas.log"
    -		sudo apt-get --assume-yes install libatlas-base-dev > "${TMP}" 2>&1
    -		check_success $? "PHP dependencies failed" "${TMP}" "${DEBUG}"
    -		[[ $? -ne 0 ]] && exit_with_image 1 "${STATUS_ERROR}" "PHP dependencies failed."
    +	STATUS_VARIABLES+=( "${FUNCNAME[0]}='true'\n" )
    +}
     
    -		STATUS_VARIABLES+=( "installed_PHP_modules='true'\n" )
    +####
    +# Install the overlay and modules system
    +install_PHP_modules()
    +{
    +	if [[ ${install_PHP_modules} == "true" ]]; then
    +		display_msg --logonly info "PHP modules already installed"
    +		return
     	fi
    +	[[ ${SKIP} == "true" ]] && return
     
    -	if [[ ${installed_python} == "true" ]]; then
    -		display_msg --log info "Python and related packages already installed."
    -	else
    -		# Doing all the python dependencies at once can run /tmp out of space, so do one at a time.
    -		# This also allows us to display progress messages.
    -		M=" for ${OS^}"
    -		R="-${PI_OS}"
    -		if [[ ${PI_OS} == "buster" ]]; then
    -			# Force pip upgrade, without this installations on Buster fail
    -			pip3 install --upgrade pip > /dev/null 2>&1
    -		elif [[ ${PI_OS} != "bullseye" && ${PI_OS} != "bookworm" ]]; then
    -			# TODO: is this an error?  Unknown OS?
    -			M=""
    -			R=""
    -		fi
    -
    -	    display_msg --logonly info "Attempting to locate Python dependency file"
    -
    -		local PREFIX="${ALLSKY_REPO}/requirements"
    -		for REQUIREMENTS_FILE in "${PREFIX}${R}-${LONG_BITS}.txt" \
    -			"${PREFIX}${R}.txt" \
    -			"${PREFIX}-${LONG_BITS}.txt" \
    -			"${PREFIX}.txt" \
    -			"END"
    -		do
    -			if [[ ${REQUIREMENTS_FILE} == "END" ]]; then
    -	        	display_msg --log error "Unable to find a requirements file!"
    -				exit_with_image 1 "${STATUS_ERROR}" "No requirements file"
    -			fi
    +	display_msg --log progress "Installing PHP modules and dependencies."
    +	TMP="${ALLSKY_LOGS}/PHP_modules.log"
    +	run_aptGet php-zip php-sqlite3 python3-pip > "${TMP}" 2>&1
    +	check_success $? "PHP module installation failed" "${TMP}" "${DEBUG}" ||
    +		exit_with_image 1 "${STATUS_ERROR}" "PHP module install failed."
     
    -	    	if [[ -f ${REQUIREMENTS_FILE} ]]; then
    -	        	display_msg --logonly info "Using '${REQUIREMENTS_FILE}'"
    -				break
    -			else
    -	        	display_msg --logonly debug "${REQUIREMENTS_FILE} - File not found"
    -			fi
    -		done
    +	TMP="${ALLSKY_LOGS}/libatlas.log"
    +	run_aptGet libatlas-base-dev > "${TMP}" 2>&1
    +	check_success $? "PHP dependencies failed" "${TMP}" "${DEBUG}" ||
    +		exit_with_image 1 "${STATUS_ERROR}" "PHP dependencies failed."
     
    -		local NUM_TO_INSTALL=$( wc -l < "${REQUIREMENTS_FILE}" )
    -		
    -		# See how many have already been installed - if all, then skip this step.
    -		local NAME="Python_dependencies"
    -		local NUM_INSTALLED="$( set | grep -c "^${NAME}" )"
    -		if [[ ${NUM_INSTALLED} -eq "${NUM_TO_INSTALL}" ||
    -				${installed_Python_dependencies} == "true" ]]; then
    -			display_msg --logonly info "Skipping: ${NAME} - all packages already installed"
    -		else
    -			# AG - Bookworm mod 12/10/23
    -			if [[ ${PI_OS} == "bookworm" ]]; then
    -				local PKGs="python3-full libgfortran5 libopenblas0-pthread"
    -				display_msg --log progress "Installing ${PKGs}."
    -				local TMP="${ALLSKY_INSTALLATION_LOGS}/python3-full.log"
    -				# shellcheck disable=SC2086
    -				sudo apt-get --assume-yes install ${PKGs} > "${TMP}" 2>&1
    -				check_success $? "${PKGs} install failed" "${TMP}" "${DEBUG}"
    -				[[ $? -ne 0 ]] && exit_with_image 1 "${STATUS_ERROR}" "${PKGs} install failed."
    -
    -				python3 -m venv "${ALLSKY_PYTHON_VENV}" --system-site-packages
    -				activate_python_venv
    -			fi
    +	STATUS_VARIABLES+=( "${FUNCNAME[0]}='true'\n" )
    +}
     
    -			# AG - Temporary fix to ensure that all dependencies are available for the Allsky modules
    -			# as the flow upgrader needs to load each module and if the dependencies are missing this will
    -			# fail
    -			if [[ -d "${ALLSKY_PYTHON_VENV}" ]]; then
    -				if [[ -d "${PRIOR_ALLSKY_DIR}/venv/lib" ]]; then
    -					cp -arn "${PRIOR_ALLSKY_DIR}/venv/lib" "${ALLSKY_PYTHON_VENV}/"
    -				fi
    -			fi
    +####
    +# Install all the Python packages
    +install_Python()
    +{
    +	declare -n v="${FUNCNAME[0]}"
    +	if [[ ${v} == "true" ]]; then
    +		display_msg --logonly info "Python and related packages already installed"
    +		return
    +	fi
    +	[[ ${SKIP} == "true" ]] && return
    +
    +	local PREFIX  REQUIREMENTS_FILE  M  R  NUM_TO_INSTALL
    +	local NAME  PKGs  TMP  COUNT  C  PACKAGE  STATUS_NAME  L  M  MSG
    +
    +	# Doing all the python dependencies at once can run /tmp out of space, so do one at a time.
    +	# This also allows us to display progress messages.
    +	M=" for ${PI_OS^}"
    +	R="-${PI_OS}"
    +	if [[ ${PI_OS} == "buster" ]]; then
    +		# Force pip upgrade, without this installations on Buster fail.
    +		pip3 install --upgrade pip > /dev/null 2>&1
    +	elif [[ ${PI_OS} != "bullseye" && ${PI_OS} != "bookworm" ]]; then
    +		display_msg --log warning "Unknown operating system: ${PI_OS}."
    +		M=""
    +		R=""
    +	fi
    +
    +    display_msg --logonly info "Locating Python dependency file"
    +	PREFIX="${ALLSKY_REPO}/requirements"
    +	REQUIREMENTS_FILE=""
    +	for file in "${PREFIX}${R}-${LONG_BITS}.txt" \
    +		"${PREFIX}${R}.txt" \
    +		"${PREFIX}-${LONG_BITS}.txt" \
    +		"${PREFIX}.txt"
    +	do
    +    	if [[ -f ${file} ]]; then
    +        	display_msg --logonly info "  Using '${file}'"
    +			REQUIREMENTS_FILE="${file}"
    +			break
    +		fi
    +	done
    +	if [[ -z ${REQUIREMENTS_FILE} ]]; then
    +       	MSG="Unable to find a requirements file!"
    +       	display_msg --log error "${MSG}"
    +		exit_with_image 1 "${STATUS_ERROR}" "${MSG}"
    +	fi
     
    -			# Astropy is no longer supported on Buster due to its dependencies requiring later versions of Python
    -			# This *hack* will force the require version of astropy onto Buster
    -			if [[ ${PI_OS} == "buster" ]]; then 
    -				display_msg --log warning "Forcing build of Astropy on ${PI_OS}."
    -				pip3 install setuptools setuptools_scm wheel cython==0.29.22 jinja2==2.10.3 numpy markupsafe==2.0.1 extension-helpers
    -				pip3 install --no-build-isolation astropy==4.3.1	
    -			fi
    -			
    -			local TMP="${ALLSKY_INSTALLATION_LOGS}/${NAME}"
    -			display_msg --log progress "Installing ${NAME}${M}:"
    -			local COUNT=0
    -			rm -f "${STATUS_FILE_TEMP}"
    -			while read -r package
    -			do
    -				((COUNT++))
    -				echo "${package}" > /tmp/package
    -				if [[ ${COUNT} -lt 10 ]]; then
    -					C=" ${COUNT}"
    -				else
    -					C="${COUNT}"
    -				fi
    +	NUM_TO_INSTALL=$( wc -l < "${REQUIREMENTS_FILE}" )
     
    -				local PACKAGE="   === Package # ${C} of ${NUM_TO_INSTALL}: [${package}]"
    -				# Need indirection since the ${STATUS_NAME} is the variable name and we want its value.
    -				local STATUS_NAME="${NAME}_${COUNT}"
    -				eval "STATUS_VALUE=\${${STATUS_NAME}}"
    -				if [[ ${STATUS_VALUE} == "true" ]]; then
    -					display_msg --log progress "${PACKAGE} - already installed."
    -					continue
    -				fi
    -				display_msg --log progress "${PACKAGE}"
    +	if [[ ${PI_OS} == "bookworm" ]]; then
    +		PKGs="python3-full libgfortran5 libopenblas0-pthread"
    +		display_msg --log progress "Installing ${PKGs}."
    +		TMP="${ALLSKY_LOGS}/python3-full.log"
    +		# shellcheck disable=SC2086
    +		run_aptGet ${PKGs} > "${TMP}" 2>&1
    +		check_success $? "${PKGs} install failed" "${TMP}" "${DEBUG}" ||
    +			exit_with_image 1 "${STATUS_ERROR}" "${PKGs} install failed."
     
    -				L="${TMP}.${COUNT}.log"
    -				local M="${NAME} [${package}] failed"
    -				pip3 install --no-warn-script-location -r /tmp/package > "${L}" 2>&1
    -				# These files are too big to display so pass in "0" instead of ${DEBUG}.
    -				if ! check_success $? "${M}" "${L}" 0 ; then
    -					rm -fr "${PIP3_BUILD}"
    +		python3 -m venv "${ALLSKY_PYTHON_VENV}" --system-site-packages
    +		activate_python_venv
    +	fi
    +
    +	# Temporary fix to ensure that all dependencies are available for the Allsky modules as the
    +	# flow upgrader needs to load each module and if the dependencies are missing this will fail.
    +	if [[ -d "${ALLSKY_PYTHON_VENV}" && -d "${PRIOR_PYTHON_VENV}" ]]; then
    +		display_msg --logonly info "Copying '${PRIOR_PYTHON_VENV}' to '${ALLSKY_PYTHON_VENV}'"
    +		cp -arn "${PRIOR_PYTHON_VENV}" "${ALLSKY_PYTHON_VENV}/"
    +	fi
    +
    +	# Astropy is no longer supported on Buster due to its dependencies requiring later versions of Python.
    +	# This *hack* will force the require version of Astropy onto Buster.
    +	if [[ ${PI_OS} == "buster" ]]; then
    +		NAME="Astrophy"
    +		display_msg --log progress "Forcing build of ${NAME} on ${PI_OS}."
    +		TMP="${ALLSKY_LOGS}/${NAME}.log"
    +		{ 
    +			PKGs="setuptools setuptools_scm wheel cython==0.29.22"
    +			PKGs+=" jinja2==2.10.3 numpy markupsafe==2.0.1 extension-helpers"
    +			# shellcheck disable=SC2086
    +			pip3 install ${PKGs} && pip3 install --no-build-isolation astropy==4.3.1	
    +		} > "${TMP}" 2>&1
    +		check_success $? "${NAME} install failed" "${TMP}" "${DEBUG}" ||
    +			exit_with_image 1 "${STATUS_ERROR}" "${NAME} install failed."
    +	fi
    +
    +	NAME="Python_dependencies"
    +	TMP="${ALLSKY_LOGS}/${NAME}"
    +	display_msg --log progress "Installing ${NAME}${M}:"
    +	COUNT=0
    +	rm -f "${STATUS_FILE_TEMP}"
    +	while read -r package
    +	do
    +		((COUNT++))
    +		echo "${package}" > /tmp/package
    +		# Make the numbers line up.
    +		if [[ ${COUNT} -lt 10 ]]; then
    +			C=" ${COUNT}"
    +		else
    +			C="${COUNT}"
    +		fi
     
    -					# Add current status
    -					update_status_from_temp_file
    +		PACKAGE="   === Package # ${C} of ${NUM_TO_INSTALL}: [${package}]"
    +		STATUS_NAME="${NAME}_${COUNT}"
    +		# Need indirection since the ${STATUS_NAME} is the variable name and we want its value.
    +		if [[ ${!STATUS_NAME} == "true" ]]; then
    +			display_msg --log progress "${PACKAGE} - already installed."
    +			continue
    +		fi
    +		display_msg --log progress "${PACKAGE}"
     
    -					exit_with_image 1 "${STATUS_ERROR}" "${M}."
    -				fi
    -				echo "${STATUS_NAME}='true'"  >> "${STATUS_FILE_TEMP}"
    -			done < "${REQUIREMENTS_FILE}"
    +		L="${TMP}.${COUNT}.log"
    +		M="${NAME} [${package}] failed"
    +		pip3 install --no-warn-script-location -r /tmp/package > "${L}" 2>&1
    +		# These files are too big to display so pass in "0" instead of ${DEBUG}.
    +		if ! check_success $? "${M}" "${L}" 0 ; then
    +			rm -fr "${PIP3_BUILD}"
     
    -			# Add the status back in.
    +			# Add current status
     			update_status_from_temp_file
    -		fi
     
    -		STATUS_VARIABLES+=( "installed_python='true'\n" )
    +			exit_with_image 1 "${STATUS_ERROR}" "${M}."
    +		fi
    +		echo "${STATUS_NAME}='true'"  >> "${STATUS_FILE_TEMP}"
    +	done < "${REQUIREMENTS_FILE}"
    +
    +	# Add the status back in.
    +	update_status_from_temp_file
    +
    +	# On Pi 5 models we need to replace rpi.gpi with lgpio.
    +	# This should be done by adafruit-blinka.
    +	# The code is in setup.py to do this but it
    +	# doesn't appear to work hence we are forcing it here.
    +	# gpiozero decodes the Pi revision number to calculate the Pi version so until the Pi 6 is 
    +	# released this code will detect all future versions of the Pi 5
    +	#
    +	# NOTE: rpi-gpi and rpi-lgpio cannot co-exist but since blinka is not installing either we
    +	# don't currently have to worry about removing rpi-gpio before installing rpi-lgpio
    +	#
    +	local CMD="from gpiozero import Device"
    +	CMD+="\nDevice.ensure_pin_factory()"
    +	CMD+="\nprint(Device.pin_factory.board_info.model)"
    +	pimodel="$( echo -e "${CMD}" | python3 2>/dev/null )"	# hide error since it only applies to Pi 5.
    +
    +	# if we are on the pi 5 then install lgpio, using the virtual environment which will always
    +	# exist on the pi 5
    +	if [[ ${pimodel:0:1} == "5" ]]; then
    +		display_msg --log progress "Updating GPIO to lgpio"
    +		activate_python_venv
    +		pip3 install rpi-lgpio > /dev/null 2>&1
     	fi
     
    -	if [[ ${installing_Trutype_fonts} != "true" ]]; then
    -		display_msg --log progress "Installing Trutype fonts."
    -		TMP="${ALLSKY_INSTALLATION_LOGS}/msttcorefonts.log"
    -		local M="Trutype fonts failed"
    -		sudo apt-get --assume-yes install msttcorefonts > "${TMP}" 2>&1
    -		check_success $? "${M}" "${TMP}" "${DEBUG}" || exit_with_image 1 "${STATUS_ERROR}" "${M}"
    -		STATUS_VARIABLES+=( "installing_Trutype_fonts='true'\n" )
    +	STATUS_VARIABLES+=( "${FUNCNAME[0]}='true'\n" )
    +}
    +
    +####
    +# Install the overlay and modules system
    +install_overlay()
    +{
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
    +
    +	display_msg --log progress "Setting up default modules and overlays."
    +	# Some of these will get overwritten later if the user has prior versions.
    +	cp -ar "${ALLSKY_REPO}/overlay" "${ALLSKY_REPO}/modules" "${ALLSKY_CONFIG}"
    +
    +	# MY_OVERLAY_TEMPLATES is not in ALLSKY_REPI and we haven't restored
    +	# anything yet, so create the directory.
    +	mkdir -p "${MY_OVERLAY_TEMPLATES}"
    +#xx TODO: these are done in set_permissions, so remove from here:
    +#xx	sudo chgrp "${WEBSERVER_GROUP}" "${MY_OVERLAY_TEMPLATES}"
    +#xx	sudo chmod 775 "${MY_OVERLAY_TEMPLATES}"	
    +
    +	# Globals: SENSOR_WIDTH, SENSOR_HEIGHT, FULL_OVERLAY_NAME, SHORT_OVERLAY_NAME, OVERLAY_NAME
    +	SENSOR_WIDTH="$( settings ".sensorWidth" "${CC_FILE}" )"
    +	SENSOR_HEIGHT="$( settings ".sensorHeight" "${CC_FILE}" )"
    +	FULL_OVERLAY_NAME="overlay-${CAMERA_TYPE}_${CAMERA_MODEL}-${SENSOR_WIDTH}x${SENSOR_HEIGHT}-both.json"
    +	SHORT_OVERLAY_NAME="overlay-${CAMERA_TYPE}.json"
    +
    +	local OVERLAY_PATH="${ALLSKY_REPO}/overlay/config/${FULL_OVERLAY_NAME}"
    +	if [[ -f ${OVERLAY_PATH} ]]; then
    +		OVERLAY_NAME=${FULL_OVERLAY_NAME}
     	else
    -		display_msg --logonly info "Skipping: Installing Trutype fonts - already installed"
    +		OVERLAY_NAME=${SHORT_OVERLAY_NAME}
     	fi
     
    -	# Do the rest, even if we already did it in a previous installation,
    -	# in case something in the directories changed.
    +	if [[ ${WILL_USE_PRIOR} == "false" ]]; then
    +		# Set to defaults since there are no prior files.
    +		display_msg --log progress "Using overlay '${OVERLAY_NAME}'."
    +		for s in daytimeoverlay nighttimeoverlay
    +		do
    +			local VALUE=""; doV "" "OVERLAY_NAME" "${s}" "text" "${SETTINGS_FILE}"
    +		done
    +	fi
     
    -	display_msg --log progress "Setting up modules and overlays."
    -	# These will get overwritten if the user has prior versions.
    -	cp -ar "${ALLSKY_REPO}/overlay" "${ALLSKY_CONFIG}"
    -	cp -ar "${ALLSKY_REPO}/modules" "${ALLSKY_CONFIG}"
    +	STATUS_VARIABLES+=( "${FUNCNAME[0]}='true'\n" )
    +}
     
    -	# Normally makeChanges.sh handles creating the "overlay.json" file, but the
    -	# Camera-Specific Overlay (CSO) file didn't exist when makeChanges was called,
    -	# so we have to set it up here.
    -	local CSO="${ALLSKY_OVERLAY}/config/overlay-${CAMERA_TYPE}.json"
    -	local O="${ALLSKY_OVERLAY}/config/overlay.json"		# generic name
    -	if [[ -f ${CSO} ]]; then
    -		display_msg "${LOG_TYPE}" progress "Copying '${CSO}' to 'overlay.json'."
    -		cp "${CSO}" "${O}"
    -	else
    -		display_msg --log error "'${CSO}' does not exist; unable to create default overlay file."
    -	fi
     
    -	sudo mkdir -p "${ALLSKY_MODULE_LOCATION}/modules"
    -	sudo chown -R "${ALLSKY_OWNER}:${WEBSERVER_GROUP}" "${ALLSKY_MODULE_LOCATION}"
    -	sudo chmod -R 774 "${ALLSKY_MODULE_LOCATION}"			
    +####
    +log_info()
    +{
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
    +
    +	display_msg --logonly info "PI_OS = ${PI_OS}"
    +##	display_msg --logonly info "/etc/os-release:\n$( indent "$( grep -v "URL" /etc/os-release )" )"
    +	display_msg --logonly info "uname = $( uname -a )"
    +	display_msg --logonly info "id = $( id )"
    +
    +	STATUS_VARIABLES+=( "${FUNCNAME[0]}='true'\n" )
     }
     
     
     ####
     check_if_buster()
     {
    -	STATUS_VARIABLES+=("check_if_buster='true'\n")
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
    +	[[ ${SKIP} == "true" ]] && return
    +	local MSG
     
     	[[ ${PI_OS} != "buster" ]] && return
     
     	MSG="WARNING: You are running the older Buster operating system."
    -	MSG="${MSG}\n\nSupport for it will be dropped in a future Allsky release.\n"
    -	MSG="${MSG}\nWe recommend doing a fresh install of Bookworm on a clean SD card now."
    -	if [[ ${PRIOR_CAMERA_TYPE} == "RPi" ]]; then
    -		MSG="${MSG}\nRPi cameras have more features on newer operating systems.\n"
    -	fi
    -	MSG="${MSG}\n\nDo you want to continue anyhow?"
    +	MSG+="\n\n\n>>> This is the last Allsky release that will support Buster. <<<\n\n"
    +	MSG+="\nWe recommend doing a fresh install of Bookworm 64-bit on a clean SD card now."
    +	MSG+="\n\nDo you want to continue anyhow?"
     	if ! whiptail --title "${TITLE}" --yesno --defaultno "${MSG}" 20 "${WT_WIDTH}" 3>&1 1>&2 2>&3; then
     		display_msg --logonly info "User running Buster and elected not to continue."
     		exit_installation 0 "${STATUS_NOT_CONTINUE}" "After Buster check."
     	fi
     	display_msg --logonly info "User running Buster and elected to continue."
    +
    +	STATUS_VARIABLES+=( "${FUNCNAME[0]}='true'\n" )
     }
     
     
     ####
    -# Display an image the user will see when they go to the WebUI.
    +# Display an image the user will see when they go to the WebUI during installation.
     display_image()
     {
    +	local IMAGE_OR_CUSTOM="${1}"
    +	local FULL_FILENAME  FILENAME  EXTENSION  IMAGE_NAME  COLOR  CUSTOM_MESSAGE  MSG  X  I
    +
     	# ${ALLSKY_TMP} may not exist yet, i.e., at the beginning of installation.
     	mkdir -p "${ALLSKY_TMP}"
     
    -	local FULL_FILENAME FILENAME EXTENSION
     	if [[ -s ${SETTINGS_FILE} ]]; then		# The file may not exist yet.
     		FULL_FILENAME="$( settings ".filename" )"
     		FILENAME="${FULL_FILENAME%.*}"
    @@ -2464,39 +3240,45 @@ display_image()
     		EXTENSION="jpg"
     	fi
     
    -	if [[ ${1} != "--custom" ]]; then
    -		local IMAGE_NAME="${1}"
    -		I="${ALLSKY_TMP}/${FILENAME}.${EXTENSION}"
    -		if [[ -z ${IMAGE_NAME} ]]; then		# No IMAGE_NAME means remove the image
    -			display_msg --logonly info "Removing prior notification image."
    -			rm -f "${I}"
    -			return
    +	I="${ALLSKY_TMP}/${FILENAME}.${EXTENSION}"
    +	if [[ -z ${IMAGE_OR_CUSTOM} ]]; then		# No IMAGE_OR_CUSTOM means remove the image
    +		display_msg --logonly info "Removing prior notification image."
    +		rm -f "${I}"
    +		return
    +	fi
    +
    +	if [[ ${IMAGE_OR_CUSTOM} == "--custom" ]]; then
    +		# Create custom message
    +		COLOR="${2}"
    +		CUSTOM_MESSAGE="${3}"
    +
    +		MSG="Displaying custom notification image: $( echo -e "${CUSTOM_MESSAGE}" | tr '\n' ' ' )"
    +		display_msg --logonly info "${MSG}"
    +		MSG="$( "${ALLSKY_SCRIPTS}/generate_notification_images.sh" \
    +			--directory "${ALLSKY_TMP}" \
    +			"${FILENAME}" "${COLOR}" "" "" "" "" \
    +			"" "10" "${COLOR}" "${EXTENSION}" "" "${CUSTOM_MESSAGE}"  2>&1 >/dev/null )"
    +		if [[ -n ${MSG} ]]; then
    +			display_msg --logonly info "${MSG}"
     		fi
    +	else
    +		IMAGE_NAME="${IMAGE_OR_CUSTOM}"
     
     		if [[ ${IMAGE_NAME} == "ConfigurationNeeded" && -f ${POST_INSTALLATION_ACTIONS} ]]; then
     			# Add a message the user will see in the WebUI.
    -			WEBUI_MESSAGE="Actions needed.  See ${POST_INSTALLATION_ACTIONS}."
    -			"${ALLSKY_SCRIPTS}/addMessage.sh" "warning" "${WEBUI_MESSAGE}"
    +			MSG="Actions needed.  See ${POST_INSTALLATION_ACTIONS}."
    +			X="${POST_INSTALLATION_ACTIONS/${ALLSKY_HOME}/}"
    +			"${ALLSKY_SCRIPTS}/addMessage.sh" "warning" "${MSG}" "${X}"
     
     			# This tells allsky.sh not to display a message about actions since we just did.
     			touch "${POST_INSTALLATION_ACTIONS}_initial_message"
     		fi
     
    -		display_msg --logonly info "Displaying notification image '${IMAGE_NAME}.${EXTENSION}'"
    -		cp "${ALLSKY_NOTIFICATION_IMAGES}/${IMAGE_NAME}.${EXTENSION}" "${I}" 2>/dev/null
    -	else
    -		# Create custom message
    -		local COLOR="${2}"
    -		local CUSTOM_MESSAGE="${3}"
    -
    -		MSG="Displaying custom notification image: $( echo -e "${CUSTOM_MESSAGE}" | tr '\n' ' ' )"
    -		display_msg --logonly info "${MSG}"
    -		"${ALLSKY_SCRIPTS}/generate_notification_images.sh" \
    -			--directory "${ALLSKY_TMP}" \
    -			"${FILENAME}" "${COLOR}" "" "" "" "" \
    -			"" "10" "${COLOR}" "${EXTENSION}" "" "${CUSTOM_MESSAGE}"   > /dev/null
    +		X="${IMAGE_NAME}.${EXTENSION}"
    +		display_msg --logonly info "Displaying notification image '${X}'"
    +		cp "${ALLSKY_NOTIFICATION_IMAGES}/${X}" "${I}" ||
    +			display_msg --log info "WARNING: unable to copy '${X}' to '${I}'"
     	fi
    -
     }
     
     
    @@ -2514,105 +3296,120 @@ exit_with_image()
     
     
     ####
    +# Sort the specified settings file to be the same as the options file.
    +sort_settings_file()
    +{
    +	local FILE="${1}"
    +
    +	display_msg --logonly info "Sorting settings file '${FILE}'."
    +
    +	"${ALLSKY_SCRIPTS}/convertJSON.php" \
    +		--convert \
    +		--order \
    +		--settings-file "${FILE}" \
    +		--options-file "${OPTIONS_FILE}" \
    +		> "${TMP_FILE}" 2>&1
    +	if [[ $? -ne 0 ]]; then
    +		MSG="Unable to sort settings file '${FILE}': $( < "${TMP_FILE}" ); ignoring"
    +		display_msg --log error "${MSG}"
    +		return 1
    +	fi
    +
    +	cp "${TMP_FILE}" "${FILE}"
    +	return 0
    +}
    +
    +####
    +# Check if we restored all prior settings.
    +# Global: CONFIGURATION_NEEDED
     check_restored_settings()
     {
    +	local IMG  AFTER  MSG
    +
    +	if [[ ${ALLSKY_VERSION} == "${PRIOR_ALLSKY_VERSION}" ]]; then
    +		CONFIGURATION_NEEDED="false"
    +		display_msg --logonly info "Re-installed same version; no configuration or reboot needed."
    +		return
    +	fi
    +
    +	for s in "${ALLSKY_CONFIG}/settings_"*
    +	do
    +		[[ -f ${s} ]] && sort_settings_file "${s}"
    +	done
    +
     	if [[ ${RESTORED_PRIOR_SETTINGS_FILE} == "true" && \
    -	  	  ${RESTORED_PRIOR_CONFIG_SH} == "true" && \
    -	  	  ${RESTORED_PRIOR_FTP_SH} == "true" ]]; then
    -		# We restored all the prior settings no configuration is needed.
    +	  	  ${COPIED_PRIOR_CONFIG_SH} == "true" && \
    +	  	  ${COPIED_PRIOR_FTP_SH} == "true" ]]; then
    +		# We restored all the prior settings so no configuration is needed.
     		# However, check if a reboot is needed.
     		CONFIGURATION_NEEDED="false"
    -		IMG=""					# Removes existing image
     		if [[ ${REBOOT_NEEDED} == "true" ]]; then
     			IMG="RebootNeeded"
    +		else
    +			IMG=""					# Blank name removes existing image
     		fi
     		display_image "${IMG}"
    -		return 0
    +		return
     	fi
     
    -	local AFTER
     	if [[ ${REBOOT_NEEDED} == "true" ]]; then
     		AFTER="rebooting"
     	else
     		AFTER="installation is complete"
     	fi
     	if [[ ${RESTORED_PRIOR_SETTINGS_FILE} == "false" ]]; then
    -		MSG="Default settings were created for your ${CAMERA_TYPE} camera."
    -		MSG="${MSG}\n\nHowever, you must update them by going to the"
    -		MSG="${MSG} 'Allsky Settings' page in the WebUI after ${AFTER}."
    -		whiptail --title "${TITLE}" --msgbox "${MSG}" 12 "${WT_WIDTH}" 3>&1 1>&2 2>&3
    -	fi
    -	if [[ ${RESTORED_PRIOR_CONFIG_SH} == "false" || \
    -	  	${RESTORED_PRIOR_FTP_SH} == "false" ]]; then
    -		MSG="Default files were created for:"
    -		[[ ${RESTORED_PRIOR_CONFIG_SH} == "false" ]] && MSG="${MSG}\n   config.sh"
    -		[[ ${RESTORED_PRIOR_FTP_SH}    == "false" ]] && MSG="${MSG}\n   ftp-settings.sh"
    -		MSG="${MSG}\n\nHowever, you must update them by going to the"
    -		MSG="${MSG} 'Editor' page in the WebUI after ${AFTER}."
    +		MSG="Default settings were created for your ${CAMERA_TYPE} ${CAMERA_MODEL} camera."
    +		MSG+="\n\nHowever, you must update them by going to the"
    +		MSG+=" 'Allsky Settings' page in the WebUI after ${AFTER}."
     		whiptail --title "${TITLE}" --msgbox "${MSG}" 12 "${WT_WIDTH}" 3>&1 1>&2 2>&3
     	fi
     
    -	display_image "ConfigurationNeeded"
     	CONFIGURATION_NEEDED="true"
     }
     
     
     ####
    -# See if the new ZWO exposure algorithm should be used.
    -check_new_exposure_algorithm()
    -{
    -	local FIELD="experimentalExposure"
    -	local NEW="$( settings ".${FIELD}" )"
    -	[[ ${NEW} -eq 1 ]] && return
    -
    -	MSG="There is a new auto-exposure algorithm for nighttime images that initial testing indicates"
    -	MSG="${MSG} it creates better images at night and during the day-to-night transition."
    -	MSG="${MSG}\n\nDo you want to use it?"
    -	if whiptail --title "${TITLE}" --yesno "${MSG}" 15 "${WT_WIDTH}"  3>&1 1>&2 2>&3; then
    -		display_msg --logonly info "Enabling ${FIELD}."
    -		update_json_file ".${FIELD}" 1 "${SETTINGS_FILE}"
    -	else
    -		display_msg --logonly info "User elected NOT to use ${FIELD}."
    -	fi
    -
    -	STATUS_VARIABLES+=( "check_new_exposure_algorithm='true'\n" )
    -}
    -
    -
    -####
    -remind_run_check_allsky()
    -{
    -	MSG="After you've configured Allsky, run:"
    -	MSG="${MSG}\n&nbsp; &nbsp; &nbsp; check_allsky.sh"
    -	MSG="${MSG}\nto check for any issues.  You can also run it whenever you make changes."
    -	"${ALLSKY_SCRIPTS}/addMessage.sh" "info" "${MSG}"
    -	display_msg --logonly info "Added message about running 'check_allsky.sh'."
    -
    -	STATUS_VARIABLES+=( "remind_run_check_allsky='true'\n" )
    -}
    -
    -
    -####
    +# Do every time as a reminder.
     remind_old_version()
     {
    -	if [[ -n ${PRIOR_ALLSKY} ]]; then
    +	[[ ${SKIP} == "true" ]] && return
    +
    +	if [[ ${USE_PRIOR_ALLSKY} == "true" ]]; then
     		MSG="When you are sure everything is working with the new Allsky release,"
    -		MSG="${MSG} remove your old version in '${PRIOR_ALLSKY_DIR}' to save disk space."
    +		MSG+=" remove your old version in '${PRIOR_ALLSKY_DIR}' to save disk space."
     		whiptail --title "${TITLE}" --msgbox "${MSG}" 12 "${WT_WIDTH}" 3>&1 1>&2 2>&3
     		display_msg --logonly info "Displayed message about removing '${PRIOR_ALLSKY_DIR}'."
     	fi
     }
     
    +####
    +# Check if the extra modules need to be reinstalled.
    +# Do every time as a reminder.
     update_modules()
     {
    -	if [[ ${EXTRA_MODULES_INSTALLED} == "true" && ${INSTALLED_VENV} == "true" ]]; then
    +	local X  MSG
    +
    +	# Nothing to do if the extra modules aren't installed.
    +	X="$( find "${ALLSKY_MODULE_LOCATION}/modules" -type f -name "*.py" -print -quit 2> /dev/null )"
    +	[[ -z ${X} ]] && return
    +
    +# xxxxxx    ALEX TODO: check the CURRENT ${ALLSKY_PYTHON_VENV} or ${PRIOR_PYTHON_VENV} ?
    +
    +	# If a venv isn't already installed then the install/update will create it,
    +	# but warn the user to reinstall the extra modules.
    +	if [[ -d ${ALLSKY_PYTHON_VENV} && ! -d ${PRIOR_PYTHON_VENV} ]]; then
     		MSG="You appear to have the Allsky Extra modules installed."
    -		MSG="${MSG}\nPlease reinstall these using the normal instructions at"
    -		MSG="${MSG}  https://github.com/Alex-developer/allsky-modules"
    -		MSG="${MSG}\nThe extra modules will not function until you have reinstalled them."
    +		MSG+="\nPlease reinstall these using the normal instructions at"
    +		MSG+="\n   https://github.com/AllskyTeam/allsky-modules"
    +		MSG+="\nThe extra modules will not function until you have reinstalled them."
     		whiptail --title "${TITLE}" --msgbox "${MSG}" 12 "${WT_WIDTH}" 3>&1 1>&2 2>&3
    -		display_msg --logonly info "Reminded user to re install the extra modules."
    +
    +		display_msg info "Don't forget to re-install your Allsky extra modules."
    +		display_msg --logonly info "Reminded user to re-install the extra modules."
    +		echo -e "\n\n========== ACTION NEEDED:\n${MSG}" >> "${POST_INSTALLATION_ACTIONS}"
     	fi
    +
    +	STATUS_VARIABLES+=( "${FUNCNAME[0]}='true'\n" )
     }
     
     clear_status()
    @@ -2634,24 +3431,28 @@ update_status_from_temp_file()
     ####
     exit_installation()
     {
    -	[[ -z ${FUNCTION} ]] && display_msg "${LOG_TYPE}" info "\nENDING INSTALLATON AT $(date).\n"
     	local RET="${1}"
    -
    -	# If STATUS_LINE is set, add that and all STATUS_VARIABLES to the status file.
     	local STATUS_CODE="${2}"
     	local MORE_STATUS="${3}"
    +	local MORE  S  Q
    +
    +	# If STATUS_CODE is set, add it and all STATUS_VARIABLES to the status file.
     	if [[ -n ${STATUS_CODE} ]]; then
     		if [[ ${STATUS_CODE} == "${STATUS_CLEAR}" ]]; then
     			clear_status
     		else
    -			if [[ -n ${MORE_STATUS} ]]; then
    -				if [[ ${MORE_STATUS} == "${STATUS_CODE}" ]]; then
    -					MORE_STATUS=""
    -				else
    -					MORE_STATUS="; MORE_STATUS='${MORE_STATUS}'"
    -				fi
    +			if [[ -n ${MORE_STATUS} && ${MORE_STATUS} != "${STATUS_CODE}" ]]; then
    +				Q="'"	# single quote.  Escape it:
    +				MORE="; MORE_STATUS='${MORE_STATUS//${Q}/\\${Q}}'"
    +			else
    +				MORE=""
    +			fi
    +			if [[ ${RESTORE} == "true" ]]; then
    +				S="RESTORATION"
    +			else
    +				S="INSTALLATION"
     			fi
    -			echo -e "STATUS_INSTALLATION='${STATUS_CODE}'${MORE_STATUS}" > "${STATUS_FILE}"
    +			echo -e "STATUS_${S}='${STATUS_CODE}'${MORE}" > "${STATUS_FILE}"
     			update_status_from_temp_file
     			echo -e "${STATUS_VARIABLES[@]}" >> "${STATUS_FILE}"
     
    @@ -2670,11 +3471,25 @@ exit_installation()
     		fi
     	fi
     
    +	[[ -z ${FUNCTION} ]] && display_msg --logonly info "ENDING ${IorR}.\n"
    +
     	# Don't exit for negative numbers.
     	[[ ${RET} -ge 0 ]] && exit "${RET}"
     }
     
     
    +####
    +# Remove the point release from the version
    +# Format of a version (_PP is optional point release):
    +#	12345678901234
    +#	vYYYY.MM.DD_PP
    +
    +function remove_point_release()
    +{
    +	# Get just the base portion.
    +	echo "${1:0:11}"
    +}
    +
     ####
     handle_interrupts()
     {
    @@ -2683,55 +3498,50 @@ handle_interrupts()
     	exit_installation 1 "${STATUS_INT}" "Saving status."
     }
     
    +
    +####
    +# Set the current Allsky status and log a message.
    +do_allsky_status()
    +{
    +	local STATUS="${1}"
    +	display_msg --logonly info "Setting Allsky status to '${!STATUS}'."
    +	set_allsky_status "${!STATUS}"
    +}
    +
    +install_installer_dependencies()
    +{
    +	declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
    +	[[ ${SKIP} == "true" ]] && return
    +
    +	display_msg --log progress "Installing installer dependencies."
    +	TMP="${ALLSKY_LOGS}/installer.dependencies.log"
    +	{
    +		sudo apt-get update && run_aptGet gawk jq
    +	} > "${TMP}" 2>&1
    +	check_success $? "gawk,jq installation failed" "${TMP}" "${DEBUG}" ||
    +		exit_with_image 1 "${STATUS_ERROR}" "gawk,jq install failed."
    +
    +	STATUS_VARIABLES+=( "${FUNCNAME[0]}='true'\n" )
    +}
    +
     ############################################## Main part of program
     
     ##### Calculate whiptail sizes
    -calc_wt_size
    +WT_WIDTH="$( calc_wt_size )"
     
     ##### Check arguments
     OK="true"
    -HELP="false"
     DEBUG=0
     DEBUG_ARG=""
     LOG_TYPE="--logonly"	# by default we only log some messages but don't display
    -IN_TESTING="false"
    -
    -[[ $( get_branch ) != "${GITHUB_MAIN_BRANCH}" ]] && IN_TESTING="true"
    -
    -if [[ ${IN_TESTING} == "true" ]]; then
    -	DEBUG=1; DEBUG_ARG="--debug"; LOG_TYPE="--log"
    -
    -	T="${ALLSKY_HOME}/told"
    -	if [[ ! -f ${T} ]]; then
    -		MSG="\n"
    -		MSG="${MSG}Testers, until we go-live with this release, debugging is automatically on."
    -		MSG="${MSG}\n\nPlease set Debug Level to 3 during testing."
    -		MSG="${MSG}\n"
    -
    -		MSG="${MSG}\nChanges from prior branch:"
    -
    -		MSG="${MSG}\n * Support for the newest ZWO cameras."
    -		MSG="${MSG}\n * Support for a couple new RPi cameras."
    -		MSG="${MSG}\n * Bug fixes in the Overlay Editor and Module manager"
    -
    -		MSG="${MSG}\n\nIf you agree, enter:    yes"
    -		A=$(whiptail --title "*** MESSAGE FOR TESTERS ***" --inputbox "${MSG}" 26 "${WT_WIDTH}"  3>&1 1>&2 2>&3)
    -		if [[ $? -ne 0 || ${A} != "yes" ]]; then
    -			MSG="\nYou need to TYPE 'yes' to continue the installation."
    -			MSG="${MSG}\nThis is to make sure you read it.\n"
    -			display_msg info "${MSG}"
    -			exit 0
    -		fi
    -		touch "${T}"
    -	fi
    -fi
    -
    +HELP="false"
     UPDATE="false"
    +FIX="false"
    +RESTORE="false"
     FUNCTION=""
    -TESTING="false"
     while [ $# -gt 0 ]; do
     	ARG="${1}"
    -	case "${ARG}" in
    +	case "${ARG,,}" in
     		--help)
     			HELP="true"
     			;;
    @@ -2740,51 +3550,110 @@ while [ $# -gt 0 ]; do
     			DEBUG_ARG="${ARG}"		# we can pass this to other scripts
     			LOG_TYPE="--log"
     			;;
    +#XXX TODO: is --update still needed?
     		--update)
     			UPDATE="true"
     			;;
    +		--fix)
    +			FIX="true"
    +			;;
    +		--restore)
    +			RESTORE="true"
    +			;;
    +		--skip)
    +			SKIP="true"
    +			;;
     		--function)
     			FUNCTION="${2}"
     			shift
     			;;
    -		--testing)
    -			TESTING="true"			# TODO: developer testing - skip many steps 
    -TESTING="${TESTING}"	# xxx keeps shellcheck quiet
    -			;;
     		*)
    -			display_msg --log error "Unknown argument: '${ARG}'."
    +			display_msg --log error "Unknown argument: '${ARG}'." >&2
     			OK="false"
     			;;
     	esac
     	shift
     done
    +[[ ${OK} == "false" ]] && usage_and_exit 1
    +[[ ${HELP} == "true" ]] && usage_and_exit 0
    +
    +if [[ ${RESTORE} == "true" && ! -d ${PRIOR_ALLSKY_DIR} ]]; then
    +	echo -e "\nERROR: You requested a restore but no prior Allsky was found at '${PRIOR_ALLSKY_DIR}'.\n" >&2
    +	exit 1
    +fi
     
    +IorR="INSTALLATION"		# Installation (default) or Restoration
     
    -if [[ -n ${FUNCTION} ]]; then
    -	# Don't log when a single function is executed.
    +if [[ -n ${FUNCTION} || ${FIX} == "true" ]]; then
    +	# Don't log when a single function is executed or we're fixing things.
     	DISPLAY_MSG_LOG=""
     else
    -	mkdir -p "${ALLSKY_INSTALLATION_LOGS}"
    +	mkdir -p "${ALLSKY_LOGS}" || display_msg --log "error" "Unable to make ${ALLSKY_LOGS}"
    +
    +	if [[ ${RESTORE} == "true" ]]; then
    +		DISPLAY_MSG_LOG="${ALLSKY_LOGS}/restore.log"
    +		STATUS_FILE="${ALLSKY_LOGS}/restore_status.txt"
    +		IorR="RESTORATION"
    +		V="$( get_version "${PRIOR_ALLSKY_DIR}/" )"		# Returns "" if no version file.
    +		V="${V:-prior version}"
    +		SHORT_TITLE="Allsky Restorer"
    +		TITLE="${SHORT_TITLE} - from ${ALLSKY_VERSION} to ${V}"
    +		NOT_RESTORED="NO CURRENT VERSION"
    +	else
    +		V="${ALLSKY_VERSION}"
    +	fi
    +	MSG="STARTING ${IorR} OF ${V}.\n"
    +	display_msg --logonly info "${MSG}"
    +fi
    +
    +[[ ${FIX} == "true" ]] && do_fix				# does not return
    +
    +#shellcheck disable=SC2119
    +if [[ $( get_branch ) != "${GITHUB_MAIN_BRANCH}" ]]; then
    +	DEBUG=1; DEBUG_ARG="--debug"; LOG_TYPE="--log"
     
    -	display_msg "${LOG_TYPE}" info "STARTING INSTALLATON AT $(date).\n"
    +	T="${ALLSKY_HOME}/told"
    +	if [[ ! -f ${T} ]]; then
    +		MSG="\nTesters, until we go-live with this release, debugging is automatically on."
    +		MSG+="\n\nPlease set Debug Level to 3 during testing."
    +		MSG+="\n"
    +
    +		MSG+="\nMajor changes from prior release:"
    +		MSG+="\n * ftp-settings.sh and config.sh are gone and"
    +		MSG+="\n   their settings are in the WebUI's 'Allsky Settings' page."
    +		MSG+="\n * ZWO library 1.33 included and supports newest cameras."
    +		MSG+="\n * Setting names are now lowercase."
    +		MSG+="\n * WebUI sections are hidden by default."
    +
    +		MSG+="\n\nIf you want to continue with the installation, enter:    yes"
    +		title="*** MESSAGE FOR TESTERS ***"
    +		A=$( whiptail --title "${title}" --inputbox "${MSG}" 26 "${WT_WIDTH}"  3>&1 1>&2 2>&3 )
    +		if [[ $? -ne 0 || ${A} != "yes" ]]; then
    +			MSG="\nYou must type 'yes' to continue the installation."
    +			MSG+="\nThis is to make sure you read it.\n"
    +			display_msg info "${MSG}"
    +			exit 0
    +		fi
    +		touch "${T}"
    +	fi
     fi
     
    -[[ ${HELP} == "true" ]] && usage_and_exit 0
    -[[ ${OK} == "false" ]] && usage_and_exit 1
     
     trap "handle_interrupts" SIGTERM SIGINT
     
     # See if we should skip some steps.
     # When most function are called they add a variable with the function's name set to "true".
    -if [[ -z ${FUNCTION} && -s ${STATUS_FILE} ]]; then
    -	# Initially just get the status.
    +if [[ -z ${FUNCTION} && -s ${STATUS_FILE} && ${RESTORE} == "false" ]]; then
    +
    +	# Initially just get the STATUS and MORE_STATUS.
     	# After that we may clear the file or get all the variables.
    -	eval "$( grep STATUS_INSTALLATION "${STATUS_FILE}" )"
    +	eval "$( grep "^STATUS_INSTALLATION" "${STATUS_FILE}" )"
    +	[[ $? -ne 0 ]] && exit_installation 1 ""	# "" means do NOT update the status file
     
     	if [[ ${STATUS_INSTALLATION} == "${STATUS_OK}" ]]; then
     		MSG="The last installation completed successfully."
    -		MSG="${MSG}\n\nDo you want to re-install from the beginning?"
    -		MSG="${MSG}\n\nSelecting <No> will exit the installation without making any changes."
    +		MSG+="\n\nDo you want to re-install from the beginning?"
    +		MSG+="\n\nSelecting <No> will exit the installation without making any changes."
     		if whiptail --title "${TITLE}" --yesno "${MSG}" 15 "${WT_WIDTH}"  3>&1 1>&2 2>&3; then
     			display_msg --log progress "Re-starting installation after successful install."
     			clear_status
    @@ -2794,16 +3663,17 @@ if [[ -z ${FUNCTION} && -s ${STATUS_FILE} ]]; then
     		fi
     	elif [[ ${STATUS_INSTALLATION} == "${STATUS_NO_FINISH_REBOOT}" ]]; then
     		MSG="The installation completed successfully but the following needs to happen"
    -		MSG="${MSG} before Allsky is ready to run:"
    -		MSG2="\n\n    1. Verify your settings in the WebUi's 'Allsky Settings' page."
    -		MSG2="${MSG2}\n    2. Reboot the Pi."
    +		MSG+=" before Allsky is ready to run:"
    +		MSG2="\n"
    +		MSG2+="\n    1. Verify your settings in the WebUi's 'Allsky Settings' page."
    +		MSG2+="\n    2. Reboot the Pi."
     		MSG3="\n\nHave you already performed those steps?"
     		if whiptail --title "${TITLE}" --yesno "${MSG}${MSG2}${MSG3}" 15 "${WT_WIDTH}"  3>&1 1>&2 2>&3; then
     			MSG="\nCongratulations, you successfully installed Allsky version ${ALLSKY_VERSION}!"
    -			MSG="${MSG}\nAllsky is starting.  Look in the 'Live View' page of the WebUI to ensure"
    -			MSG="${MSG}\nimages are being taken.\n"
    +			MSG+="\nAllsky is starting.  Look in the 'Live View' page of the WebUI to ensure"
    +			MSG+="\nimages are being taken.\n"
     			display_msg --log progress "${MSG}"
    -			sudo systemctl start allsky
    +			start_Allsky
     
     			# Update status
     			sed -i \
    @@ -2815,13 +3685,13 @@ if [[ -z ${FUNCTION} && -s ${STATUS_FILE} ]]; then
     		fi
     		exit_installation 0 "" ""
     	else
    - 		[[ -n ${MORE_STATUS} ]] && MORE_STATUS=" - ${MORE_STATUS}"
    +		[[ -n ${MORE_STATUS} ]] && MORE_STATUS=" - ${MORE_STATUS}"
     		MSG="You have already begun the installation."
    -		MSG="${MSG}\n\nThe last status was: ${STATUS_INSTALLATION}${MORE_STATUS}"
    -		MSG="${MSG}\n\nDo you want to continue where you left off?"
    +		MSG+="\n\nThe last status was: ${STATUS_INSTALLATION}${MORE_STATUS}"
    +		MSG+="\n\nDo you want to continue where you left off?"
     		if whiptail --title "${TITLE}" --yesno "${MSG}" 15 "${WT_WIDTH}"  3>&1 1>&2 2>&3; then
     			MSG="Continuing installation.  Steps already performed will be skipped."
    -			MSG="${MSG}\n   The last status was: ${STATUS_INSTALLATION}${MORE_STATUS}"
    +			MSG+="\n  The last status was: ${STATUS_INSTALLATION}${MORE_STATUS}"
     			display_msg --log progress "${MSG}"
     
     			#shellcheck disable=SC1090		# file doesn't exist in GitHub
    @@ -2839,7 +3709,7 @@ if [[ -z ${FUNCTION} && -s ${STATUS_FILE} ]]; then
     
     		else
     			MSG="Do you want to restart the installation from the beginning?"
    -			MSG="${MSG}\n\nSelecting <No> will exit the installation without making any changes."
    +			MSG+="\n\nSelecting <No> will exit the installation without making any changes."
     			if whiptail --title "${TITLE}" --yesno "${MSG}" 15 "${WT_WIDTH}"  3>&1 1>&2 2>&3; then
     				display_msg --log progress "Restarting installation."
     			else
    @@ -2850,37 +3720,51 @@ if [[ -z ${FUNCTION} && -s ${STATUS_FILE} ]]; then
     	fi
     fi
     
    -##### Does a prior Allsky exist? If so, set PRIOR_ALLSKY and other PRIOR_* variables.
    +if [[ -z ${FUNCTION} && ${RESTORE} == "false" ]]; then
    +
    +	##### Keep track of current Allsky status
    +	mkdir -p "$( dirname "${ALLSKY_STATUS}" )"		# location of status file
    +	do_allsky_status "ALLSKY_STATUS_INSTALLING"
    +
    +	##### Log some info to help in troubleshooting.
    +	log_info
    +
    +	##### Display a message to Buster users.
    +	check_if_buster
    +fi
    +
    +##### Does a prior Allsky exist? If so, set PRIOR_ALLSKY_STYLE and other PRIOR_* variables.
     # Re-run every time in case the directory was removed.
     does_prior_Allsky_exist
     
    -##### Display a message to Buster users.
    -[[ ${check_if_buster} != "true" && -z ${FUNCTION} ]] && check_if_buster
    +[[ ${RESTORE} == "true" ]] && do_restore		# does not return
     
     ##### Display the welcome header
     [[ -z ${FUNCTION} ]] && do_initial_heading
     
     ##### See if we need to reboot at end of installation
    -[[ -n ${PRIOR_ALLSKY} ]] && is_reboot_needed "${PRIOR_ALLSKY_VERSION}" "${ALLSKY_VERSION}"
    +[[ ${USE_PRIOR_ALLSKY} == "true" ]] && is_reboot_needed "${PRIOR_ALLSKY_VERSION}" "${ALLSKY_VERSION}"
     
     ##### Determine what steps, if any, can be skipped.
     set_what_can_be_skipped "${PRIOR_ALLSKY_VERSION}" "${ALLSKY_VERSION}"
     
     ##### Stop Allsky
    -stop_allsky
    +stop_Allsky
    +
    +[[ -z ${FUNCTION} ]] && install_installer_dependencies
     
     ##### Determine what camera(s) are connected
     # Re-run every time in case a camera was connected or disconnected.
     get_connected_cameras
     
     ##### Get branch
    -[[ ${get_this_branch} != "true" ]] && get_this_branch
    +get_this_branch
     
     ##### Handle updates
     [[ ${UPDATE} == "true" ]] && do_update		# does not return
     
     ##### See if there's an old WebUI
    -[[ ${does_old_WebUI_location_exist} != "true" ]] && does_old_WebUI_location_exist
    +does_old_WebUI_location_exist
     
     ##### Executes the specified function, if any, and exits.
     if [[ -n ${FUNCTION} ]]; then
    @@ -2894,78 +3778,83 @@ display_image "InstallationInProgress"
     # Do as much of the prompting up front, then do the long-running work, then prompt at the end.
     
     ##### Prompt to use prior Allsky
    -[[ ${prompt_for_prior_Allsky} != "true" ]] && prompt_for_prior_Allsky
    +prompt_for_prior_Allsky		# Sets ${WILL_USE_PRIOR}
     
     ##### Get locale (prompt if needed).  May not return.
    -[[ ${get_desired_locale} != "true" ]] && get_desired_locale
    +get_desired_locale
     
     ##### Prompt for the camera type
     [[ ${select_camera_type} != "true" ]] && select_camera_type
     
     ##### If raspistill exists on post-Buster OS, rename it.
    -[[ ${check_for_raspistill} != "true" ]] && check_for_raspistill
    +check_for_raspistill
     
     ##### Get the new host name
    -[[ ${prompt_for_hostname} != "true" ]] && prompt_for_hostname
    +prompt_for_hostname
     
     ##### Check for sufficient swap space
    -[[ ${check_swap} != "true" ]] && check_swap
    +check_swap "install" ""
     
     ##### Optionally make ${ALLSKY_TMP} a memory filesystem
    -[[ ${check_tmp} != "true" ]] && check_tmp
    +check_tmp "install"
     
     
    -MSG="The following steps can take up to an hour depending on the speed of your Pi"
    -MSG="${MSG}\nand how many of the necessary dependencies are already installed."
    -MSG="${MSG}\nYou will see progress messages throughout the process."
    -MSG="${MSG}\nAt the end you will be prompted again for additional steps."
    -display_msg notice "${MSG}"
    +MSG="The following steps can take up to an hour depending on the speed of"
    +MSG+="\nyour Pi and how many of the necessary dependencies are already installed."
    +MSG+="\nYou will see progress messages throughout the process."
    +MSG+="\nAt the end you will be prompted again for additional steps."
    +display_msg "notice" "${MSG}"
     
     
     ##### Install web server
     # This must come BEFORE save_camera_capabilities, since it installs php.
    -[[ ${install_webserver_et_al} != "true" ]] && install_webserver_et_al
    +install_webserver_et_al
     
     ##### Install dependencies, then compile and install Allsky software
     # This will create the "config" directory and put default files in it.
    -[[ ${install_dependencies_etc} != "true" ]] && install_dependencies_etc
    +install_dependencies_etc
     
    -##### Create the file that defines the WebUI variables.
    -[[ ${create_webui_defines} != "true" ]] && create_webui_defines
    +##### Update PHP "define()" variables
    +update_php_defines
     
     ##### Create the camera type/model-specific "options" file
     # This should come after the steps above that create ${ALLSKY_CONFIG}.
    -if [[ ${save_camera_capabilities} != "true" ]]; then
    -	save_camera_capabilities "false"		# prompts on error only
    -	[[ $? -ne 0 ]] && exit_with_image 1 "${STATUS_ERROR}" "save_camera_capabilities failed."
    -fi
    +save_camera_capabilities "false"
     
     ##### Set locale.  May reboot instead of returning.
    -[[ ${set_locale} != "true" ]] && set_locale
    +set_locale
     
     ##### Create the Allsky log files
    -# Re-run every time in case permissions changed.
    -create_allsky_logs
    +create_allsky_logs "true"			# "true" == do everything
     
    -##### install the overlay and modules system
    +##### Install the overlay and modules system and things it needs
    +install_fonts
    +install_PHP_modules
    +install_Python
     install_overlay
     
    -##### Check for, and handle any prior Allsky Website
    -[[ ${handle_prior_website} != "true" ]] && handle_prior_website
    +##### Get Website checksums for optional remote Website.
    +# Do this before we change the local Website files.
    +get_checksums
     
    -##### Restore prior files if needed
    -[[ ${restore_prior_files} != "true" ]] && restore_prior_files	# prompts if prior Allsky exists
    +##### Restore prior files if needed.
    +# This MUST be called even if we know we're not using an OLD directory.
    +restore_prior_files
     
    -##### Update config.sh
    -[[ ${update_config_sh} != "true" ]] && update_config_sh
    +##### Restore prior Website files if needed.
    +# This has to come after restore_prior_files() since it may set some variables we need.
    +restore_prior_website_files
     
     ##### Set permissions.  Want this at the end so we make sure we get all files.
     # Re-run every time in case permissions changed.
     set_permissions
     
    +##### Update the sudoers file
    +do_sudoers
    +
     ##### Check if there's an old WebUI and let the user know it's no longer used.
     # Prompt user to remove any prior old-style WebUI.
    -[[ ${check_old_WebUI_location} != "true" ]] && check_old_WebUI_location
    +check_old_WebUI_location
     
     ##### See if we should reboot when installation is done.
     [[ ${REBOOT_NEEDED} == "true" ]] && ask_reboot "full"			# prompts
    @@ -2974,15 +3863,6 @@ set_permissions
     # Re-run every time to possibly remind them to update their settings.
     check_restored_settings
     
    -##### If using ZWO, prompt if the New Exposure Algorithm should be used.
    -# TODO: remove check_new_exposure_algorithm() when it's the default.
    -if [[ ${CAMERA_TYPE} == "ZWO" && ${check_new_exposure_algorithm} != "true" ]]; then
    -	check_new_exposure_algorithm
    -fi
    -
    -##### Let the user know to run check_allsky.sh.
    -[[ ${remind_run_check_allsky} != "true" ]] && remind_run_check_allsky
    -
     ##### Check if extra modules need to be reinstalled.
     update_modules
     
    @@ -2993,18 +3873,32 @@ remind_old_version
     
     ######## All done
     
    -[[ ${WILL_REBOOT} == "true" ]] && do_reboot "${STATUS_FINISH_REBOOT}" ""		# does not return
    +if [[ ${WILL_REBOOT} == "true" ]]; then
    +	do_allsky_status "ALLSKY_STATUS_SEE_WEBUI"
    +	do_reboot "${STATUS_FINISH_REBOOT}" ""		# does not return
    +fi
     
     if [[ ${REBOOT_NEEDED} == "true" ]]; then
    -	display_msg --log progress "\nInstallation is done but the Pi needs a reboot.\n"
    +	display_msg --log progress "\nInstallation is done" " but the Pi needs a reboot.\n"
    +	do_allsky_status "ALLSKY_STATUS_SEE_WEBUI"
     	exit_installation 0 "${STATUS_NO_FINISH_REBOOT}" ""
    +fi
    +
    +if [[ ${CONFIGURATION_NEEDED} == "false" ]]; then
    +	do_allsky_status "ALLSKY_STATUS_NOT_RUNNING"
    +	display_image --custom "lime" "Allsky is\nready to start"
    +	display_msg --log progress "\nInstallation is done."  "You must manually restart Allsky."
    +elif [[ ${CONFIGURATION_NEEDED} != "true" ]]; then
    +	# A status string.
    +	exit_installation 0 "${CONFIGURATION_NEEDED}" ""
     else
    -	if [[ ${CONFIGURATION_NEEDED} == "false" ]]; then
    -		display_image --custom "lime" "Allsky is\nready to start"
    -		display_msg --log progress "\nInstallation is done and Allsky is ready to start."
    -	else
    -		display_msg --log progress "\nInstallation is done but Allsky needs to be configured."
    -	fi
    -	display_msg progress "\nEnjoy Allsky!\n"
    -	exit_installation 0 "${STATUS_OK}" ""
    +	# "true"
    +	display_image "ConfigurationNeeded"
    +	do_allsky_status "ALLSKY_STATUS_SEE_WEBUI"
    +	MSG=" but Allsky needs to be configured before it will start."
    +	display_msg --log progress "\nInstallation is done" "${MSG}"
    +	display_msg progress "" "Go to the 'Allsky Settings' page of the WebUI to configure Allsky."
     fi
    +
    +display_msg progress "\nEnjoy Allsky!\n"
    +exit_installation 0 "${STATUS_OK}" ""
    \ No newline at end of file
    diff --git a/notification_images/CameraOffDuringNight.jpg b/notification_images/CameraOffDuringNight.jpg
    new file mode 100644
    index 000000000..ae47b56df
    Binary files /dev/null and b/notification_images/CameraOffDuringNight.jpg differ
    diff --git a/notification_images/CameraOffDuringNight.png b/notification_images/CameraOffDuringNight.png
    new file mode 100644
    index 000000000..03ba94d10
    Binary files /dev/null and b/notification_images/CameraOffDuringNight.png differ
    diff --git a/notification_images/Error.jpg b/notification_images/Error.jpg
    index 52cc71e04..6607e63fa 100644
    Binary files a/notification_images/Error.jpg and b/notification_images/Error.jpg differ
    diff --git a/notification_images/Error.png b/notification_images/Error.png
    index 3b9016627..fed462912 100644
    Binary files a/notification_images/Error.png and b/notification_images/Error.png differ
    diff --git a/remoteWebsiteInstall.sh b/remoteWebsiteInstall.sh
    new file mode 100755
    index 000000000..6115f5646
    --- /dev/null
    +++ b/remoteWebsiteInstall.sh
    @@ -0,0 +1,838 @@
    +#!/bin/bash
    +
    +# shellcheck disable=SC2317
    +
    +# Install or upgrade a remote Allsky Website.
    +
    +# TODO: handle interrupts like in install.sh
    +
    +# shellcheck disable=SC2155
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )" )"
    +ME="$( basename "${BASH_ARGV0}" )"
    +
    +#shellcheck source-path=.
    +source "${ALLSKY_HOME}/variables.sh"					|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/functions.sh"					|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/installUpgradeFunctions.sh"	|| exit "${EXIT_ERROR_STOP}"
    +
    +# display_msg() sends log entries to this file.
    +# shellcheck disable=SC2034
    +DISPLAY_MSG_LOG="${ALLSKY_LOGS}/${ME/.sh/}.log"
    +
    +# Config variables
    +HAVE_NEW_CONFIG="false"
    +HAVE_PRIOR_CONFIG="false"
    +HAVE_NEW_REMOTE_CONFIG="false"
    +HAVE_REALLY_OLD_REMOTE_CONFIG="false"
    +CONFIG_TO_USE=""		# which Website configuration file to use?
    +CONFIG_MESSAGE=""
    +REMOTE_WEBSITE_EXISTS="false"
    +
    +# Dialog size variables
    +DIALOG_WIDTH="$( tput cols )"; ((DIALOG_WIDTH -= 10 ))
    +DIALOG_HEIGHT=25
    +
    +# Remote connectivity variables
    +REMOTE_URL="$( settings ".remotewebsiteurl" "${SETTINGS_FILE}" )"
    +REMOTE_USER="$( settings ".REMOTEWEBSITE_USER" "${ALLSKY_ENV}" )"
    +REMOTE_HOST="$( settings ".REMOTEWEBSITE_HOST" "${ALLSKY_ENV}" )"
    +REMOTE_PORT="$( settings ".REMOTEWEBSITE_PORT" "${ALLSKY_ENV}" )"
    +REMOTE_PASSWORD="$( settings ".REMOTEWEBSITE_PASSWORD" "${ALLSKY_ENV}" )"
    +REMOTE_DIR="$( settings ".remotewebsiteimagedir" "${SETTINGS_FILE}" )"
    +REMOTE_PROTOCOL="$( settings ".remotewebsiteprotocol" "${SETTINGS_FILE}" )"
    +REMOTE_PROTOCOL="${REMOTE_PROTOCOL,,}"		# convert to lowercase
    +
    +#### TODO: this script needs to support ALL protocols, not just *ftp*.
    +# When it does, remove this check.
    +if [[ ${REMOTE_PROTOCOL} != "sftp" && ${REMOTE_PROTOCOL} != "ftp" && ${REMOTE_PROTOCOL} != "ftps" ]]; then
    +	echo -e "\n\n"
    +	echo    "************* NOTICE *************"
    +	echo    "This script currently only supports ftp protocols."
    +	echo    "Support for the '${REMOTE_PROTOCOL}' protocol will be added in"
    +	echo    "the first point release."
    +	echo -e "\n"
    +	echo    "In the meantime, if you have an existing remote Allsky Website,"
    +	echo    "it should continue to work."
    +	echo -e "\n"
    +
    +	exit 0
    +fi
    +
    +# Titles for various dialogs
    +# don't use:  DIALOG_BACK_TITLE="Allsky Remote Website Installer"
    +DIALOG_WELCOME_TITLE="Allsky Remote Website Installer"
    +DIALOG_PRE_CHECK="${DIALOG_WELCOME_TITLE} - Pre Installation Checks"
    +DIALOG_INSTALL="Installing Remote Website"
    +DIALOG_DONE="Remote Website Installation Completed"
    +DIALOG_TITLE_LOG="Allsky Remote Website Installation Log"
    +
    +# Old Allksy Website files that should be removed if they exist.
    +OLD_CONFIG_NAME="config.js"
    +OLD_FILES_TO_REMOVE=( \
    +	"${OLD_CONFIG_NAME}" \
    +	"${ALLSKY_WEBSITE_CONFIGURATION_NAME}" \
    +	"getTime.php" \
    +	"virtualsky.json" \
    +	"README.md" \
    +	"myImages")
    +
    +############################################## functions
    +
    +# Prompt the user to enter (y)/(yes) or (n)/(no).
    +# This function is only used when running in text (--text) mode.
    +function enter_yes_no()
    +{
    +	local TEXT="${1}"
    +	local RESULT=1
    +	local ANSWER
    +
    +	while true; do
    +		echo -e "${TEXT}"
    +		read -r -p "Do you want to continue? (y/n): " ANSWER
    +		ANSWER="${ANSWER,,}"	# convert to lowercase
    +
    +		if [[ ${ANSWER} == "y" || ${ANSWER} == "yes" ]]; then
    +			return 0
    +		elif [[ ${ANSWER} == "n" || ${ANSWER} == "no" ]]; then
    +			return 1
    +		else
    +			echo -e "\nInvalid response. Please enter y/yes or n/no."
    +		fi
    +	done
    +
    +	return ${RESULT}
    +}
    +
    +# prompt the user to press any key.
    +# This function is only used when running in text (--text) mode.
    +function press_any_key()
    +{
    +	echo -e "${1}\nPress any key to continue..."
    +	read -r -n1 -s
    +}
    +
    +# Add a common heading to the dialog text.
    +function add_dialog_heading()
    +{
    +	local DIALOG_TEXT="${1}"
    +
    +	## We no longer add the remote URL but have left this code in case we want
    +	## to add something else in the future.
    +	## Only the:   ITEM_TO_ADD=xxx   line should need changing.
    +	echo "${DIALOG_TEXT}"
    +	return
    +
    +	if [[ ${TEXT_ONLY} == "true" ]]; then
    +		DIALOG_RED="${RED}"
    +		DIALOG_NORMAL="${NC}"
    +	fi
    +
    +	local ITEM_TO_ADD="${REMOTE_URL}"
    +	local PADDING=$(( ((DIALOG_WIDTH-6) - ${#ITEM_TO_ADD}) / 2 ))
    +	local ITEM_TO_ADD="$( printf "%${PADDING}s%s" "" "${ITEM_TO_ADD}" )"
    +
    +	echo -e "\n${DIALOG_RED}${ITEM_TO_ADD}${DIALOG_NORMAL}\n${DIALOG_TEXT}"
    +}
    +
    +# Displays the specified type of Dialog, or in text mode just displays the text.
    +# ${1} - The box type
    +# ${2} - The title for the dialog
    +# ${3} - The text to disply in the dialog
    +# ${4} - Optional additional arguments to dialog
    +#
    +# Return - 1 if the user selected "No"; 0 otherwise
    +function display_box()
    +{
    +	local DIALOG_TYPE="${1}"
    +	local DIALOG_TITLE="${2}"
    +	local DIALOG_TEXT="${3}"
    +	local MORE_ARGS="${4}"
    +
    +	DIALOG_TEXT="$( add_dialog_heading "${DIALOG_TEXT}" )"
    +	if [[ ${TEXT_ONLY} == "true" ]]; then
    +		local RET=0
    +		if [[ ${DIALOG_TYPE} == "--msgbox" ]]; then
    +			press_any_key "${DIALOG_TEXT}"
    +		elif [[ ${DIALOG_TYPE} == "--yesno" ]]; then
    +			enter_yes_no "${DIALOG_TEXT}"
    +			RET=$?
    +		else
    +			echo -e "${DIALOG_TEXT}"
    +		fi
    +		return ${RET}
    +	fi
    +
    +	# Don't use: it's redundant most of the time	--backtitle "${DIALOG_BACK_TITLE}" \
    +	# shellcheck disable=SC2086
    +	dialog \
    +		--colors \
    +		--title "${DIALOG_TITLE}" \
    +		${MORE_ARGS} \
    +		"${DIALOG_TYPE}" "${DIALOG_TEXT}" ${DIALOG_HEIGHT} ${DIALOG_WIDTH}
    +	return $?
    +}
    +
    +# Displays a file Dialog, or in text mode just displays the file.
    +# ${1} - The title for the dialog
    +# ${2} - The filename to display
    +#
    +# Returns - Nothing
    +function display_log_file()
    +{
    +	local DIALOG_TITLE="${1}"
    +	local FILENAME="${2}"
    +
    +	if [[ ${TEXT_ONLY} == "true" ]]; then
    +		cat "${FILENAME}"
    +		return
    +	fi
    +
    +	dialog \
    +		--clear \
    +		--colors \
    +		--title "${DIALOG_TITLE}" \
    +		--textbox "${FILENAME}" 22 77
    +}
    +
    +# Runs the pre installation checks to determine the following:
    +# - Is there a remote Website?
    +# - Which configuration file to use for the remote Website?
    +#
    +# The configuration file to use is decided using the following, in order:
    +#
    +# 1a. If ${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE} exists, use it.
    +#
    +# 1b. If ${PRIOR_REMOTE_WEBSITE_CONFIGURATION_FILE} exists,
    +#     copy it to ${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE} and use it.
    +#
    +# 2a. If there's a remote Website with a ${ALLSKY_WEBSITE_CONFIGURATION_NAME} file,
    +#     save it locally as ${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE} and use it.
    +#
    +# 2b. If there is a remote Website with an old-style configuration file (${OLD_CONFIG_NAME}),
    +#     create a NEW ${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE} and use it.
    +#     Don't bother trying to convert from old-style files.
    +function pre_install_checks()
    +{
    +	display_msg --logonly info "Start pre installation checks."
    +
    +	local MSG=""
    +	local DIALOG_TEXT  DT
    + 	DIALOG_TEXT="\nWelcome to the Allsky Remote Website Installer!\n\n"
    +	DIALOG_TEXT+="\nRunning pre installation checks.\n\n"
    +
    +	DIALOG_TEXT+="\n1  - Checking for remote Website configuration file on Pi: "
    +	display_box "--infobox" "${DIALOG_PRE_CHECK}" "${DIALOG_TEXT}"
    +
    +	if [[ -f ${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE} ]]; then
    +		# 1a.
    +		DT="FOUND"
    +		MSG="Found ${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}."
    +		display_msg --logonly info "${MSG}"
    +		HAVE_NEW_CONFIG="true"
    +
    +### FIX / TODO: Should this be used?
    +# During Allsky upgrades, if the OLD directory exists users are asked if
    +# it should be used.  If "yes", then the prior remote Website config file was
    +# copied to the new Allsky, so 1a should match.
    +# If the user answered "no", don't use OLD Allsky, we probably shouldn't either.
    +	elif [[ -f ${PRIOR_REMOTE_WEBSITE_CONFIGURATION_FILE} ]]; then
    +		# 1b.
    +		DT="FOUND ${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_NAME} in '${PRIOR_CONFIG_DIR}'"
    +		MSG="Found ${PRIOR_REMOTE_WEBSITE_CONFIGURATION_FILE}."
    +		display_msg --logonly info "${MSG}"
    +		HAVE_PRIOR_CONFIG="true"
    +
    +	else
    +		DT="NOT FOUND"
    +		display_msg --logonly info "No local config files found."
    +	fi
    +	DIALOG_TEXT+="${DT}."
    +	display_box "--infobox" "${DIALOG_PRE_CHECK}" "${DIALOG_TEXT}"
    +
    +	DIALOG_TEXT+="\n2  - Checking for existing remote Website: "
    +	display_box "--infobox" "${DIALOG_PRE_CHECK}" "${DIALOG_TEXT}"
    +	local INDENT="     "
    +	REMOTE_WEBSITE_EXISTS="$( check_if_website_exists )"
    +	if [[ ${REMOTE_WEBSITE_EXISTS} == "true" ]]; then
    +
    +		# If we didn't find a remote Website configuration file on the Pi,
    +		# it's "should be" an old-style Website since the user wasn't
    +		# using the WebUI to configure it.
    +
    +		DIALOG_TEXT+="FOUND."
    +		display_box "--infobox" "${DIALOG_PRE_CHECK}" "${DIALOG_TEXT}"
    +
    +		# 2a.
    +		DIALOG_TEXT+="\n${INDENT}* Checking it for new-style configuration file: "
    +		display_box "--infobox" "${DIALOG_PRE_CHECK}" "${DIALOG_TEXT}"
    +		local NEW_CONFIG_FILES=("${ALLSKY_WEBSITE_CONFIGURATION_NAME}")
    +		if check_if_files_exist "${REMOTE_URL}" "or" "${NEW_CONFIG_FILES[@]}" ; then
    +			HAVE_NEW_REMOTE_CONFIG="true"
    +			DIALOG_TEXT+="Found."
    +			display_box "--infobox" "${DIALOG_PRE_CHECK}" "${DIALOG_TEXT}"
    +			MSG="Found a current configuration file on the remote server."
    +			display_msg --logonly info "${MSG}"
    +		else
    +			# 2b.
    +			DIALOG_TEXT+="Not found."
    +			display_box "--infobox" "${DIALOG_PRE_CHECK}" "${DIALOG_TEXT}"
    +
    +			DIALOG_TEXT+="\n${INDENT}* Checking it for old-style configuration file:"
    +			display_box "--infobox" "${DIALOG_PRE_CHECK}" "${DIALOG_TEXT}"
    +			local REALLY_OLD_CONFIG_FILES=("${OLD_CONFIG_NAME}")
    +			if check_if_files_exist "${REMOTE_URL}" "or" "${REALLY_OLD_CONFIG_FILES[@]}" ; then
    +				HAVE_REALLY_OLD_REMOTE_CONFIG="true"
    +				DT="FOUND"
    +				MSG="Found old-style ${OLD_CONFIG_NAME} file on the remote Website."
    +				display_msg --logonly info "${MSG}"
    +			else
    +				# This "shouldn't" happen - the remote Website should have SOME type
    +				# of configuration file.
    +				DT="NOT FOUND"
    +			fi
    +			DIALOG_TEXT+="${DT}."
    +			display_box "--infobox" "${DIALOG_PRE_CHECK}" "${DIALOG_TEXT}"
    +		fi
    +	else
    +		# No remote Website found.
    +		DIALOG_TEXT+="NOT FOUND."
    +		display_box "--infobox" "${DIALOG_PRE_CHECK}" "${DIALOG_TEXT}"
    +
    +		if [[ ${HAVE_NEW_CONFIG} == "true" || ${HAVE_PRIOR_CONFIG} == "true" ]]; then
    +			DIALOG_TEXT+="${DIALOG_RED}"
    +			DIALOG_TEXT+="\n${INDENT}WARNING: a remote configuration file exists"
    +			DIALOG_TEXT+="\n${INDENT}but a remote Website wasn't found."
    +			DIALOG_TEXT+="\n${INDENT}What is the configuration file for?"
    +			DIALOG_TEXT+="${DIALOG_NORMAL}"
    +			display_box "--infobox" "${DIALOG_PRE_CHECK}" "${DIALOG_TEXT}"
    +		fi
    +
    +	fi
    +
    +	if [[ ${HAVE_NEW_CONFIG} == "true" ]]; then
    +		if [[ ${HAVE_NEW_REMOTE_CONFIG} == "true" ]]; then
    +			MSG="A remote configuration file was found but using the local version instead."
    +		else
    +			MSG="Using the local remote configuration file (no remote file found)."
    +		fi
    +		display_msg --logonly info "${MSG}"
    +		CONFIG_TO_USE="local"	# it may be old or current format
    +		CONFIG_MESSAGE="the current remote"
    +
    +	elif [[ ${HAVE_PRIOR_CONFIG} == "true" ]]; then
    +		local B="$( basename "${PRIOR_ALLSKY_DIR}" )"
    +		MSG="Using the ${B} configuration file."
    +		display_msg --logonly info "${MSG}"
    +		CONFIG_TO_USE="prior"	# it may be old or current format
    +		CONFIG_MESSAGE="the ${B}"
    +
    +	elif [[ ${HAVE_NEW_REMOTE_CONFIG} == "true" ]]; then
    +		MSG="Using new-style Website configuration file on the remote Website;"
    +		MSG+=" it will be downloaded and saved locally."
    +		display_msg --logonly info "${MSG}"
    +		CONFIG_TO_USE="remoteNew"
    +		CONFIG_MESSAGE="the remote Website's"
    +
    +	elif [[ ${HAVE_REALLY_OLD_REMOTE_CONFIG} == "true" ]]; then
    +		MSG="Old ${OLD_CONFIG_NAME} found."
    +		MSG+=" Creating a new configuration file that the user must manually update."
    +		display_msg --logonly info "${MSG}"
    +		CONFIG_TO_USE="remoteReallyOld"
    +		CONFIG_MESSAGE="a new"
    +
    +	else
    +		MSG="Unable to determine the configuration file to use. A new one will be created."
    +		display_msg --logonly info "${MSG}"
    +		CONFIG_TO_USE="new"
    +	fi
    +
    +	DIALOG_TEXT+="\n     * Checking ability to upload to it: "
    +	display_box "--infobox" "${DIALOG_PRE_CHECK}" "${DIALOG_TEXT}"
    +	display_msg --logonly info "Checking remote Website connectivity."
    +	local ERR="$( check_connectivity )"
    +	if [[ -z ${ERR} ]]; then
    +		DIALOG_TEXT+="PASSED."
    +		display_box "--infobox" "${DIALOG_PRE_CHECK}" "${DIALOG_TEXT}"
    +		show_debug_message "The remote Website connectivity test succeeded."
    +	else
    +		local ERROR_MSG="\nERROR: The remote Website connectivity check failed."
    +		display_aborted "${ERROR_MSG}" "${ERR}"
    +	fi
    +
    +	display_msg --logonly info "Completed pre installation checks."
    +
    +	# Prompt the user to continue.  This is so they can see the above messages.
    +	DIALOG_TEXT+="\n\n\n${DIALOG_UNDERLINE}Press OK to continue${DIALOG_NORMAL}"
    +	display_box "--msgbox" "${DIALOG_PRE_CHECK}" "${DIALOG_TEXT}"
    +}
    +
    +# Displays the welcome dialog indicating what steps will be taken
    +function display_welcome()
    +{
    +	if [[ ${TEXT_ONLY} == "true" ]]; then
    +		DIALOG_RED="${RED}"
    +		DIALOG_NORMAL="${NC}"
    +	fi
    +
    +	if [[ ${AUTO_CONFIRM} == "false" ]]; then
    +		display_msg --logonly info "Displaying the welcome dialog."
    +		local DIALOG_TEXT="\n\
    + This script will now:\n\n\
    + \
    +  1) Use ${CONFIG_MESSAGE} configuration file.\n\
    +   2) Upload the new remote Website code.\n\
    +   3) Upload the remote Website configuration file.\n\
    +   4) Enable the remote Website.\n\n\
    + \
    + ${DIALOG_RED}WARNING! This will:${DIALOG_NORMAL}\n\
    +  - Overwrite the old Allsky web files on the remote server.\n\
    +  - Remove any old Allsky files from the remote server.\n\n\n\
    + ${DIALOG_UNDERLINE}Are you sure you wish to continue?${DIALOG_NORMAL}"
    +
    +		if ! display_box "--yesno" "${DIALOG_WELCOME_TITLE}" "${DIALOG_TEXT}" ; then
    +			display_aborted "--user" "at the Welcome dialog" ""
    +		fi
    +	else
    +		display_msg --logonly info "Ignored welcome prompt as auto confirm option specified."
    +	fi
    +}
    +
    +# Displays the aborted dialog. This is used when an error is encountered or the user cancels.
    +# ${1} - Extra text to display in the dialog
    +# ${2} - Error message (or "" if no error)
    +function display_aborted()
    +{
    +	if [[ ${1} == "--user" ]]; then
    +		local ABORT_MSG="USER ABORTED INSTALLATION"
    +		shift
    +	else
    +		local ABORT_MSG="INSTALLATION ABORTED"
    +	fi
    +	local EXTRA_TEXT="${1}"
    +	local ERROR_MSG="${2}"
    +
    +	display_msg --logonly info "${ABORT_MSG} ${EXTRA_TEXT}.\n"
    +	local MSG="\nInstallation of the remote Website aborted ${EXTRA_TEXT}."
    +
    +	if [[ -n ${ERROR_MSG} ]]; then
    +		local DIALOG_PROMPT="${MSG}\n\n"
    +		DIALOG_PROMPT+="${DIALOG_UNDERLINE}Would you like to view the error message?${DIALOG_NORMAL}"
    +		if display_box "--yesno" "${DIALOG_INSTALL}" "${DIALOG_PROMPT}" ; then
    +			display_box "--msgbox" "${DIALOG_TITLE_LOG}" "${ERROR_MSG}" "--scrollbar"
    +if false; then
    +			display_log_file "${DIALOG_TITLE_LOG}" "${DISPLAY_MSG_LOG}"
    +fi
    +		fi
    +	fi
    +
    +	clear	# Gets rid of background color from last 'dialog' command.
    +	# Not needed:   display_msg info "${ERROR_MSG}"
    +
    +	exit 1
    +}
    +
    +# Displays the completed dialog, used at the end of the installation process.
    +function display_complete()
    +{
    +	local EXTRA_TEXT=""
    +	local E=" Please use the WebUI's 'Editor' page to replace any '${NEED_TO_UPDATE}' with the correct values."
    +	if [[ ${CONFIG_TO_USE} == "new"  ]]; then
    +		EXTRA_TEXT="\nA new configuration file was created for your remote Website."
    +		EXTRA_TEXT+="${E}"
    +	elif [[ ${CONFIG_TO_USE} == "remoteReallyOld" ]]; then
    +		EXTRA_TEXT="\nYou have a very old remote Allsky Website so a new configuration file was created."
    +		EXTRA_TEXT+="${E}"
    +	fi
    +
    +	display_msg --logonly info "INSTALLATION COMPLETED.\n"
    +
    +	local DIALOG_TEXT="\n\
    +  The installation of the remote Website is complete\n\
    +  and the remote Website should be working.\n\n\
    +  Please check it.\n\n\
    +  Use the WebUI's 'Editor' page to change settings for your Website.${EXTRA_TEXT}"
    +	display_box "--msgbox" "${DIALOG_DONE}" "${DIALOG_TEXT}"
    +
    +	clear	# Gets rid of background color from last 'dialog' command.
    +	display_msg info "\nEnjoy your remote Allsky Website!\n"
    +}
    +
    +# Check connectivity to the remote Website by trying to upload a file to it.
    +# Return "" for success, else the error message.
    +function check_connectivity()
    +{
    +	local TEST_FILE="${ME}.txt"
    +	local ERR
    +
    +	if ERR="$( "${ALLSKY_SCRIPTS}/testUpload.sh" --website --silent --file "${TEST_FILE}" 2>&1 )" ; then
    +		remove_remote_file "${TEST_FILE}" "do not check"
    +		echo ""
    +	else
    +		echo "${ERR}"
    +	fi
    +}
    +
    +# Displays a debug message in the log if the debug flag has been specified on the command line.
    +function show_debug_message()
    +{
    +	if [[ ${DEBUG} == "true" ]]; then
    +		display_msg --logonly debug "${1}"
    +	fi
    +}
    +
    +# Update a Website config file if it's an old version.
    +function update_old()
    +{
    +	local FILE="${1}"
    +
    +	local PRIOR_VERSION="$( settings ".${WEBSITE_CONFIG_VERSION}" "${FILE}" )"
    +	local NEW_VERSION="$( settings ".${WEBSITE_CONFIG_VERSION}" "${REPO_WEBCONFIG_FILE}" )"
    +	if [[ ${PRIOR_VERSION} < "${NEW_VERSION}" ]]; then
    +		# Old version, so update to format of the current version.
    +		update_old_website_config_file "${FILE}" "${PRIOR_VERSION}" "${NEW_VERSION}"
    +		return 0
    +	fi
    +	return 1
    +}
    +
    +# Creates the remote Website configuration file if needed.
    +# See 'pre_install_checks' for details on which configuration file is used.
    +function create_website_config()
    +{
    +	local MSG="Creating configuration file from ${CONFIG_MESSAGE}"
    +	display_msg --logonly info "${MSG}"
    +
    +	if [[ ${CONFIG_TO_USE} == "new" || ${CONFIG_TO_USE} == "remoteReallyOld" ]]; then
    +		# Need a new config file so copy it from the repo and replace as many
    +		# placeholders as we can.
    +		local DEST_FILE="${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}"
    +		cp "${REPO_WEBCONFIG_FILE}" "${DEST_FILE}"
    +		replace_website_placeholders "remote"
    +
    +		MSG="Created a new ${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_NAME}"
    +		MSG+=" from repo and updated placeholders."
    +		display_msg --logonly info "${MSG}"
    +
    +	elif [[ ${CONFIG_TO_USE} == "local" ]]; then
    +		# Using the remote config file on the Pi which may be new or old format.
    +		MSG="Using existing ${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_NAME}"
    +		if update_old "${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}" ; then
    +			MSG+=" and converting to newest format."
    +		fi
    +		display_msg --logonly info "${MSG}"
    +
    +	elif [[ ${CONFIG_TO_USE} == "prior" ]]; then
    +		# Use the config file from the prior Allsky, replacing as many placeholders as we can.
    +		# If the file is an older version, convert to the newest format.
    +		cp "${PRIOR_REMOTE_WEBSITE_CONFIGURATION_FILE}" "${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}"
    +		replace_website_placeholders "remote"
    +		update_old "${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}"
    +
    +		MSG="Copied ${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_NAME} from the"
    +		MSG+=" $( basename "${PRIOR_ALLSKY_DIR}" ) directory and updated placeholders."
    +		display_msg --logonly info "${MSG}"
    +
    +	elif [[ ${CONFIG_TO_USE} == "remoteNew" ]]; then
    +		# Use the new remote config file since none were found locally.
    +		# Replace placeholders and convert it to the newest format.
    +		# Remember that the remote file name is different than what we store on the Pi.
    +		if ERR="$( wget -O "${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}" "${REMOTE_URL}/${ALLSKY_WEBSITE_CONFIGURATION_FILE}" 2>&1 )"; then
    +			replace_website_placeholders "remote"
    +			update_old "${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}"
    +
    +			MSG="Downloaded ${ALLSKY_WEBSITE_CONFIGURATION_FILE} from ${REMOTE_URL},"
    +			MSG+=" to ${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}."
    +			display_msg --logonly info "${MSG}"
    +		else
    +			# This "shouldn't" happen since we either already checked the file exists,
    +			# or we uploaded it.
    +			MSG="Failed to download ${ALLSKY_WEBSITE_CONFIGURATION_FILE} from ${REMOTE_URL}."
    +			MSG+=" Where did it go?"
    +			display_aborted "${MSG}" "${ERR}"
    +		fi
    +	fi
    +}
    +
    +# Check if a remote file, or array of files, exist.
    +# ${1} - The base url
    +# ${2} - "and"/"or" If "and" then all files must exist, if "or" then any of the files can exist.
    +# ${3}... - the files
    +#
    +# Returns - 0 if the file(s) exist, 1 if ANY file doesn't exist.
    +function check_if_files_exist()
    +{
    +	local URL="${1}"
    +	local AND_OR="${2}"
    +	shift 2
    +	local RET_CODE=1
    +
    +	for FILE in "$@"; do
    +		url="${URL}/${FILE}"
    +		HTTP_STATUS="$( curl -o /dev/null --head --silent --write-out "%{http_code}" "$url" )"
    +
    +		local PRE_MSG="File ${FILE} ${url}"
    +		if [[ ${HTTP_STATUS} == "200" ]] ; then
    +			show_debug_message "${PRE_MSG} exists on the remote server"
    +			RET_CODE=0
    +		else
    +			show_debug_message "${PRE_MSG} does not exists on the remote server"
    +			if [[ ${AND_OR} == "and" ]]; then
    +				return 1
    +			fi
    +		fi
    +	done
    +
    +	return ${RET_CODE}
    +}
    +
    +# Deletes a file from the remote server.
    +# ${1} - The name of the file to delete
    +# ${2} - If set to "check", first check if the file exists
    +#
    +# Returns - Nothing
    +function remove_remote_file()
    +{
    +	local FILENAME="${1}"
    +	local CHECK="${2}"
    +
    +	if [[ ${CHECK} == "check" ]]; then
    +		if ! check_if_files_exist "${REMOTE_URL}" "or" "${FILENAME}" ; then
    +			show_debug_message "===== not on server"
    +			return
    +		fi
    +	fi
    +
    +# TODO: This assumes ftp is used to upload files
    +# upload.sh should accept "--remove FILE" option.
    +	local CMDS=""  ERR
    +	[[ -n ${REMOTE_DIR} ]] && CMDS="cd '${REMOTE_DIR}' ;"
    +	CMDS+=" rm -r '${FILENAME}' ; bye"
    +
    +	ERR="$( lftp -u "${REMOTE_USER},${REMOTE_PASSWORD}" \
    +					"${REMOTE_PORT}" \
    +					"${REMOTE_PROTOCOL}://${REMOTE_HOST}" \
    +				-e "${CMDS}" 2>&1 )"
    +	if [[ $? -eq 0 ]] ; then
    +		MSG="Deleted remote file '${FILENAME}'"
    +	else
    +		MSG="Unable to delete remote file '${FILENAME}': ${ERR}"
    +	fi
    +
    +	display_msg --logonly info "${MSG}"
    +}
    +
    +# Check if a remote Website exists.
    +# The check is done by looking for the following files:
    +#	If any of the ${CONFIG_FILES} files exist AND
    +#	all of the ${WEBSITE_FILES} exist then assume we have a remote Website.
    +#
    +# Returns - echo "true" if it exists, else "false"
    +function check_if_website_exists()
    +{
    +	local CONFIG_FILES=("${OLD_CONFIG_NAME}" "${ALLSKY_WEBSITE_CONFIGURATION_NAME}")
    +	local WEBSITE_FILES=("index.php" "functions.php")
    +
    +	if check_if_files_exist "${REMOTE_URL}" "or" "${CONFIG_FILES[@]}" ; then
    +		show_debug_message "Found a remote Website config file"
    +
    +		if check_if_files_exist "${REMOTE_URL}" "and" "${WEBSITE_FILES[@]}" ; then
    +			display_msg --logonly info "Found a remote Allsky Website at ${REMOTE_URL}"
    +			echo "true"
    +			return 0
    +		fi
    +	fi
    +	echo "false"
    +	return 1
    +}
    +
    +# Uploads the Website code from ${ALLSKY_WEBSITE} and removes any old Allsky
    +# files that are no longer used.
    +function upload_remote_website()
    +{
    +	if [[ ${SKIP_UPLOAD} == "true" ]]; then
    +		display_msg --logonly info "Skipping upload per user request.\n"
    +		return
    +	fi
    +
    +	local EXTRA_TEXT=""  EXCLUDE_FOLDERS=""  MSG
    +
    +	if [[ -n ${REMOTE_PORT} ]]; then
    +		REMOTE_PORT="-p ${REMOTE_PORT}"
    +	fi
    +
    +	MSG="Starting upload to the remote Website"
    +	[[ -n ${REMOTE_DIR} ]] && MSG+=" in ${REMOTE_DIR}"
    +	if [[ ${REMOTE_WEBSITE_EXISTS} == "true" ]]; then
    +
    +		# Don't upload images if the remote Website exists (we assume it already
    +		# has the images).
    +		# However, we must upload the index.php files.
    +		EXCLUDE_FOLDERS="--exclude keograms --exclude startrails --exclude videos"
    +
    +# FIX: the --include doesn't work - the files aren't uploaded.
    +# Do we need to do a second lftp mirror to upload them?
    +		EXCLUDE_FOLDERS+=" --include keograms/index.php"
    +		EXCLUDE_FOLDERS+=" --include startrails/index.php"
    +		EXCLUDE_FOLDERS+=" --include videos/index.php"
    +		MSG+=" (without videos, images, and their thumbnails)."
    +	fi
    +	display_msg --logonly info "${MSG}${EXTRA_TEXT}."
    +
    +	MSG="\n${MSG}\n\nThis may take several minutes..."
    +	display_box "--infobox" "${DIALOG_INSTALL}" "${MSG}"
    +
    +# TODO: upload.sh should have a "--mirror from_directory to_directory" option.
    +# This would also fix the problem that we're assuming the "ftp" protocol is used.
    +	local NL="$( echo -e "\n " )"		# Need space otherwise it doesn't work - not sure why
    +	local CMDS=" lcd '${ALLSKY_WEBSITE}'"
    +	CMDS+="${NL}set dns:fatal-timeout 10; set net:max-retries 2; set net:timeout 10"
    +	# shellcheck disable=SC2086
    +	if [[ -n "${REMOTE_DIR}" ]]; then
    +		CMDS+="${NL}cd '${REMOTE_DIR}'"
    +	else
    +		CMDS+="${NL}cd ."		# for debugging
    +	fi
    +	CMDS+="${NL}mirror --reverse --no-perms --verbose --overwrite --ignore-time --transfer-all"
    +	[[ -n ${EXCLUDE_FOLDERS} ]] && CMDS+=" ${EXCLUDE_FOLDERS}"
    +	CMDS+="${NL}bye"
    +
    +	local TMP="${ALLSKY_TMP}/remote_upload.txt"
    +	echo -e "CMDS=${CMDS}\n======" > "${TMP}"
    +	# shellcheck disable=SC2086
    +	lftp -u "${REMOTE_USER},${REMOTE_PASSWORD}" \
    +		 ${REMOTE_PORT} "${REMOTE_PROTOCOL}://${REMOTE_HOST}" -e "${CMDS}" >> "${TMP}" 2>&1
    +	local RET_CODE=$?
    +
    +	# Ignore stuff not supported by all FTP servers and stuff we don't want to see.
    +	local IGNORE="operation not supported|command not understood|hostname checking disabled|Overwriting old file"
    +	MSG="$( grep -v -i -E "${IGNORE}" "${TMP}" )"
    +	# If the "mirror" command causes any of the messages above,
    +	# they are counted as errors.
    +	if [[ ${RET_CODE} -ne 0 ]]; then
    +		MSG="$( echo -e "RET_CODE=${RET_CODE}\nCMDS=${CMDS}\n${MSG}" | sed -e 's/$/\\n/' )"
    +		display_aborted "while uploading Website" "${MSG}"
    +	fi
    +
    +	display_msg --logonly info "$( indent --spaces "${MSG}" )"
    +
    +	# Remove any old core files no longer required
    +	for FILE_TO_DELETE in "${OLD_FILES_TO_REMOVE[@]}"; do
    +		remove_remote_file "${FILE_TO_DELETE}" "check"
    +	done
    +
    +	display_msg --logonly info "Website upload complete"
    +}
    +
    +# Uploads the configuration file for the remote Website.
    +function upload_config_file()
    +{
    +	local MSG="\nUploading remote Allsky configuration file"
    +	display_box "--infobox" "${DIALOG_INSTALL}" "${MSG}"
    +	display_msg --logonly info "Uploading Website configuration file."
    +
    +	local ERR="$( "${ALLSKY_SCRIPTS}/upload.sh" --remote-web \
    +		"${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}" "${REMOTE_DIR}" \
    +		"${ALLSKY_WEBSITE_CONFIGURATION_NAME}" 2>&1 )"
    +	if [[ $? -eq 0 ]]; then
    +		MSG="${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE} uploaded to"
    +		MSG+="${REMOTE_DIR}/${ALLSKY_WEBSITE_CONFIGURATION_NAME}"
    +		show_debug_message "${MSG}"
    +	else
    +		display_msg --logonly info " Failed: ${ERR}"
    +		display_aborted "at the configuration file upload" "${ERR}"
    +	fi
    +}
    +
    +# Displays the script's help.
    +usage_and_exit()
    +{
    +	local RET C MSG
    +
    +	RET=${1}
    +	if [[ ${RET} -eq 0 ]]; then
    +		C="${YELLOW}"
    +	else
    +		C="${RED}"
    +	fi
    +	MSG="Usage: ${ME} [--help] [--debug] [--skipupload] [-auto] [--text]"
    +	{
    +		echo -e "\n${C}${MSG}${NC}"
    +		echo
    +		echo "'--help' displays this message and exits."
    +		echo
    +		echo "'--debug' adds addtional debugging information to the installation log."
    +		echo
    +		echo "'--skipupload' Skips uploading of the remote Website code."
    +		echo "   Must only be used if advised by Allsky support."
    +		echo
    +		echo "'--auto' Accepts all prompts by default"
    +		echo
    +		echo "'--text' Text only mode, do not use any dialogs"
    +		echo
    +	} >&2
    +	exit "${RET}"
    +}
    +
    +# Disable the remote Website.
    +function disable_remote_website()
    +{
    +	update_json_file ".useremotewebsite" "false" "${SETTINGS_FILE}"
    +	display_msg --logonly info "Remote Website temporarily disabled."
    +}
    +# Enable the remote Website.
    +function enable_remote_website()
    +{
    +	update_json_file ".useremotewebsite" "true" "${SETTINGS_FILE}"
    +	display_msg --logonly info "Remote Website enabled."
    +}
    +
    +############################################## main body
    +OK="true"
    +HELP="false"
    +SKIP_UPLOAD="false"
    +AUTO_CONFIRM="false"
    +TEXT_ONLY="false"
    +DEBUG="false"
    +
    +while [[ $# -gt 0 ]]; do
    +	ARG="${1}"
    +	case "${ARG,,}" in
    +		--help)
    +			HELP="true"
    +			;;
    +		--debug)
    +			DEBUG="true"
    +			;;
    +		--skipupload)
    +			SKIP_UPLOAD="true"
    +			;;
    +		--auto)
    +			AUTO_CONFIRM="true"
    +			;;
    +		--text)
    +			TEXT_ONLY="true"
    +			LOG_TYPE="--log"
    +			;;
    +		*)
    +			display_msg --log error "Unknown argument: '${ARG}'."
    +			OK="false"
    +			;;
    +	esac
    +	shift
    +done
    +
    +[[ ${HELP} == "true" ]] && usage_and_exit 0
    +[[ ${OK} == "false" ]] && usage_and_exit 1
    +
    +display_msg --logonly info "STARTING INSTALLATION.\n"
    +
    +pre_install_checks
    +display_welcome
    +create_website_config
    +disable_remote_website
    +upload_remote_website
    +upload_config_file
    +enable_remote_website
    +display_complete
    diff --git a/scripts/addMessage.sh b/scripts/addMessage.sh
    index 3291d35dc..e159a8e60 100755
    --- a/scripts/addMessage.sh
    +++ b/scripts/addMessage.sh
    @@ -4,16 +4,21 @@
     # If the message is already there, just update the time and count.
     
     # Allow this script to be executed manually, which requires several variables to be set.
    -[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$(realpath "$(dirname "${BASH_ARGV0}")/..")"
    -ME="$(basename "${BASH_ARGV0}")"
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
    +ME="$( basename "${BASH_ARGV0}" )"
     
    -#shellcheck disable=SC2086 source=variables.sh
    -source "${ALLSKY_HOME}/variables.sh"					|| exit ${ALLSKY_ERROR_STOP}
    +#shellcheck disable=SC1091 source=variables.sh
    +source "${ALLSKY_HOME}/variables.sh"					|| exit "${EXIT_ERROR_STOP}"
     
    -if [ $# -ne 2 ]; then
    +if [ $# -lt 2 ]; then
     	# shellcheck disable=SC2154
    -	echo -e "${wERROR}Usage: ${ME}  message_type  message${wNC}" >&2
    -	echo -e "\nWhere 'message_type' is 'success', 'warning', 'error', 'info', or 'debug'." >&2
    +	{
    +		echo -e "${wERROR}"
    +		echo    "Usage: ${ME}  message_type  message  [url]"
    +		echo -e "${wNC}"
    +		echo -e "\n'message_type' is 'success', 'warning', 'error', 'info', or 'debug'."
    +		echo -e "\n'url' is a URL to (normally) a documentation page."
    +	} >&2
     	exit 1
     fi
     
    @@ -24,22 +29,31 @@ if [[ ${TYPE} == "error" ]]; then
     	TYPE="danger"
     elif [[ ${TYPE} == "debug" ]]; then
     	TYPE="warning"
    +elif [[ ${TYPE} == "no-image" ]]; then
    +	TYPE="success"
     elif [[ ${TYPE} != "warning" && ${TYPE} != "info" && ${TYPE} != "success" ]]; then
     	echo -e "${wWARNING}Warning: unknown message type: '${TYPE}'. Using 'info'.${wNC}" >&2
     	TYPE="info"
     fi
     MESSAGE="${2}"
    -DATE="$(date '+%B %d, %r')"
    +URL="${3}"
    +DATE="$( date '+%B %d, %r' )"
     
    -# The file is tab-separated: type date count message
    -COUNT=0
    -TAB="$(echo -e "\t")"
    +# The file is tab-separated:    type  date  count  message  url
    +TAB="$( echo -e "\t" )"
     
     # Convert newlines to HTML breaks.
     MESSAGE="$( echo -en "${MESSAGE}" |
     	awk 'BEGIN { l=0; } { if (++l > 1) printf("<br>"); printf("%s", $0); }' )"
    +
    +# Make 2 spaces in a row viewable in HTML.
     MESSAGE="${MESSAGE//  /\&nbsp;\&nbsp;}"
     
    +# Convert tabs to spaces because we use tabs as field separators.
    +# Tabs in the input can either be an actual tab or \t
    +MESSAGE="${MESSAGE//${TAB}/\&nbsp;\&nbsp;\&nbsp;\&nbsp;}"
    +MESSAGE="${MESSAGE//\\t/\&nbsp;\&nbsp;\&nbsp;\&nbsp;}"
    +
     # Messages may have "/" in them so we can't use that to search in sed,
     # so use "%" instead, but because it could be in a message (although unlikely),
     # convert all "%" to the ASCII code.
    @@ -50,16 +64,17 @@ MESSAGE="${MESSAGE//%/\&\#37;}"
     ESCAPED_MESSAGE="${MESSAGE//\*/\\*}"
     
     
    -if [[ -f ${ALLSKY_MESSAGES} ]] &&  M="$(grep "${TAB}${ESCAPED_MESSAGE}$" "${ALLSKY_MESSAGES}")" ; then
    +if [[ -f ${ALLSKY_MESSAGES} ]] &&  M="$( grep "${TAB}${ESCAPED_MESSAGE}${TAB}" "${ALLSKY_MESSAGES}" )" ; then
    +	COUNT=0
     	# tail -1  in case file is corrupt and has more than one line we want.
    -	PRIOR_COUNT=$(echo -e "${M}" | cut -f3 -d"${TAB}" | tail -1)
    +	PRIOR_COUNT=$( echo -e "${M}" | cut -f3 -d"${TAB}" | tail -1 )
     
     	# If this entry is corrupted don't try to update the counter.
     	[[ ${PRIOR_COUNT} != "" ]] && ((COUNT = PRIOR_COUNT + 1))
     
     	# TODO: prior messages can have any character in them so what do we
     	# use to separate the sed components?
    -	EXPRESSION="\%${TAB}${ESCAPED_MESSAGE}$%d"
    +	EXPRESSION="\%${TAB}${ESCAPED_MESSAGE}${TAB}$%d"
     	if ! sed -i -e "${EXPRESSION}"  "${ALLSKY_MESSAGES}" ; then
     		echo "${ME}: Warning, sed -e '${EXPRESSION}' failed." >&2
     	fi
    @@ -67,4 +82,4 @@ else
     	COUNT=1
     fi
     
    -echo -e "${TYPE}${TAB}${DATE}${TAB}${COUNT}${TAB}${MESSAGE}"  >>  "${ALLSKY_MESSAGES}"
    +echo -e "${TYPE}${TAB}${DATE}${TAB}${COUNT}${TAB}${MESSAGE}${TAB}${URL}"  >>  "${ALLSKY_MESSAGES}"
    diff --git a/scripts/checkAllsky.sh b/scripts/checkAllsky.sh
    new file mode 100755
    index 000000000..ed3a0cb6e
    --- /dev/null
    +++ b/scripts/checkAllsky.sh
    @@ -0,0 +1,866 @@
    +#!/bin/bash
    +# shellcheck disable=SC2154		# referenced but not assigned - from convertJSON.php
    +
    +
    +# Check the Allsky installation and settings for missing items,
    +# inconsistent items, illegal items, etc.
    +
    +# TODO: Within a heading, group by topic, e.g., all IMG_* together.
    +# TODO: Right now the checks within each heading are in the order I thought of them!
    +
    +# Allow this script to be executed manually, which requires several variables to be set.
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
    +ME="$( basename "${BASH_ARGV0}" )"
    +
    +#shellcheck disable=SC1091 source-path=.
    +source "${ALLSKY_HOME}/variables.sh"					|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/functions.sh" 				|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/installUpgradeFunctions.sh"	|| exit "${EXIT_ERROR_STOP}"
    +
    +usage_and_exit()
    +{
    +	local RET=${1}
    +	local C=""
    +	[[ ${RET} -ne 0 ]] && C="${RED}"
    +	{
    +		echo
    +		echo -en "${C}"
    +		echo -n  "Usage: ${ME} [--help] [--fromWebUI] [--no-info] [--no-warn] [--no-error]"
    +		echo -e  "${NC}"
    +		echo
    +		echo "'--help' displays this message and exits."
    +		echo "'--fromWebUI' displays output to be displayed in the WebUI."
    +		echo "'--no-info' skips checking for Informational items."
    +		echo "'--no-warn' skips checking for Warning items."
    +		echo "'--no-error' skips checking for Error items."
    +		echo
    +	} >&2
    +	exit "${RET}"
    +}
    +
    +# Check arguments
    +OK="true"
    +HELP="false"
    +FROM_WEBUI="false"
    +CHECK_INFORMATIONAL="true"
    +CHECK_WARNINGS="true"
    +CHECK_ERRORS="true"
    +while [[ $# -gt 0 ]]; do
    +	ARG="${1}"
    +	case "${ARG,,}" in
    +		--help)
    +			HELP="true"
    +			;;
    +		--fromwebui)
    +			FROM_WEBUI="true"
    +			;;
    +		--no-info)
    +			CHECK_INFORMATIONAL="false"
    +			;;
    +		--no-warn)
    +			CHECK_WARNINGS="false"
    +			;;
    +		--no-error)
    +			CHECK_ERRORS="false"
    +			;;
    +		*)
    +			echo -e "${RED}Unknown argument: '${ARG}'${NC}" >&2
    +			OK="false"
    +			;;
    +	esac
    +	shift
    +done
    +[[ ${HELP} == "true" ]] && usage_and_exit 0
    +[[ ${OK} == "false" ]] && usage_and_exit 1
    +
    +NUM_INFOS=0
    +NUM_WARNINGS=0
    +NUM_ERRORS=0
    +
    +NUM_HEADER_CALLS=0
    +function heading()
    +{
    +	local HEADER="${1}"
    +
    +	((NUM_HEADER_CALLS++))
    +	local SUB_HEADER=""
    +	local DISPLAY_HEADER="false"
    +	case "${HEADER}" in
    +		Information)
    +			((NUM_INFOS++))
    +			if [[ ${NUM_INFOS} -eq 1 ]]; then
    +				DISPLAY_HEADER="true"
    +				SUB_HEADER=" (items that will not stop any part of Allsky from running)"
    +			fi
    +			;;
    +		Warning)
    +			((NUM_WARNINGS++))
    +			if [[ ${NUM_WARNINGS} -eq 1 ]]; then
    +				DISPLAY_HEADER="true"
    +				SUB_HEADER=" (items that may keep parts of Allsky running)"
    +			fi
    +			;;
    +		Error)
    +			((NUM_ERRORS++))
    +			if [[ ${NUM_ERRORS} -eq 1 ]]; then
    +				DISPLAY_HEADER="true"
    +				SUB_HEADER=" (items that may keep Allsky from running)"
    +			fi
    +			;;
    +		Summary)
    +			DISPLAY_HEADER="true"
    +			;;
    +		*)
    +			echo "INTERNAL ERROR in heading(): Unknown HEADER '${HEADER}'."
    +			;;
    +	esac
    +
    +	if [[ ${FROM_WEBUI} == "true" ]]; then
    +		# Don't display the header when run from the WebUI.
    +		return
    +	fi
    +
    +	if [[ ${DISPLAY_HEADER} == "true" ]]; then
    +		[[ ${NUM_HEADER_CALLS} -gt 1 ]] && echo -e "${NL}"
    +		echo -e "${STRONGs}---------- ${HEADER}${SUB_HEADER} ----------${STRONGe}${NL}"
    +	else
    +		echo "${STRONGs}-----${STRONGe}"	# Separates lines within a header group
    +	fi
    +}
    +
    +
    +# =================================================== FUNCTIONS
    +
    +# Return the min of two numbers.
    +function min()
    +{
    +	local ONE="${1}"
    +	local TWO="${2}"
    +	if [[ ${ONE} -lt ${TWO} ]]; then
    +		echo "${ONE}"
    +	else
    +		echo "${TWO}"
    +	fi
    +}
    +
    +# Make sure the env file exists.
    +function check_for_env_file()
    +{
    +	[[ -s ${ALLSKY_ENV} ]] && return 0
    +
    +	heading "Error"
    +	if [[ ! -f ${ALLSKY_ENV} ]]; then
    +		echo "'${ALLSKY_ENV}' not found!"
    +	else
    +		echo "'${ALLSKY_ENV}' is empty!"
    +	fi
    +	echo "Unable to check any remote Website or server settings."
    +	return 1
    +}
    +if check_for_env_file ; then
    +	ENV_EXISTS="true"
    +else
    +	ENV_EXISTS="false"
    +fi
    +
    +# Get all settings.  Give each set a different prefix to avoid name conflicts.
    +X="$( "${ALLSKY_SCRIPTS}/convertJSON.php" --prefix S_ --shell )"
    +if [[ $? -ne 0 ]]; then
    +	echo "${X}"
    +	exit 1
    +fi
    +eval "${X}"
    +
    +if [[ ${ENV_EXISTS} == "true" ]]; then
    +	X="$( "${ALLSKY_SCRIPTS}/convertJSON.php" --prefix E_ --shell --settings-file "${ALLSKY_ENV}" )"
    +	eval "${X}"
    +fi
    +
    +# "convertJSON.php" won't work with the CC_FILE since it has arrays.
    +C_sensorWidth="$( settings ".sensorWidth" "${CC_FILE}" )"	# Physical sensor size.
    +C_sensorHeight="$( settings ".sensorHeight" "${CC_FILE}" )"
    +
    +# Return 0 if the number is 0.0, else return 1.
    +function is_zero()
    +{
    +	local NUM="${1}"
    +	gawk -v n="${NUM}" 'BEGIN {if (n == 0.0) exit 0; else exit 1; }'
    +}
    +
    +# Return 0 if the number 0.0 - 1.0, else return 1.
    +function is_within_range()
    +{
    +	local NUM="${1}"
    +	local LOW="${2:-0.0}"
    +	local HIGH="${3:-1.0}"
    +	gawk -v n="${NUM}" -v low="${LOW}" -v high="${HIGH}" '
    +		BEGIN {if (n < low || n > high) exit 1; exit 0; }'
    +}
    +
    +# Typical minimum daytime and nighttime exposures.
    +DAY_MIN_EXPOSURE_MS=250
    +NIGHT_MIN_EXPOSURE_MS=5000
    +# Minimum total time spent on each image.
    +DAY_MIN_IMAGE_TIME_MS=$(( DAY_MIN_EXPOSURE_MS + S_daydelay ))
    +NIGHT_MIN_IMAGE_TIME_MS=$(( NIGHT_MIN_EXPOSURE_MS + S_nightdelay ))
    +MIN_IMAGE_TIME_MS="$( min "${DAY_MIN_IMAGE_TIME_MS}" "${NIGHT_MIN_IMAGE_TIME_MS}" )"
    +
    +##### Check if the delay is so short it's likely to cause problems.
    +function check_delay()
    +{
    +	local DAY_OR_NIGHT="${1}"
    +	local DELAY_MS  MIN_MS  L
    +
    +	if [[ ${DAY_OR_NIGHT} == "Daytime" ]]; then
    +		L="${S_daydelay_label}"
    +		DELAY_MS="${S_daydelay}"
    +		MIN_MS="${DAY_MIN_IMAGE_TIME_MS}"
    +	else
    +		L="${S_nightdelay_label}"
    +		DELAY_MS="${S_nightdelay}"
    +		MIN_MS="${NIGHT_MIN_IMAGE_TIME_MS}"
    +	fi
    +
    +# TODO: use the module average flow times for day and night when using "module" method.
    +# TODO: overlaymethod goes away in next release
    +
    +	# With the legacy overlay method it might take up to a couple seconds to save an image.
    +	# With the module method it can take up to 5 seconds.
    +	if [[ ${S_overlaymethod} -eq 1 ]]; then
    +		MAX_TIME_TO_PROCESS_MS=5000
    +	else
    +		MAX_TIME_TO_PROCESS_MS=2000
    +	fi
    +	if [[ ${MIN_MS} -lt ${MAX_TIME_TO_PROCESS_MS} ]]; then
    +		heading "Warning"
    +		echo "The ${WSNs}${L}${WSNe} of ${DELAY_MS} ms may be too short given the"
    +		echo "maximum expected time to save and process an image (${MAX_TIME_TO_PROCESS_MS} ms)."
    +		echo "A new image may appear before the prior one has finished processing."
    +		echo "FIX: Consider increasing ${L}."
    +	fi
    +}
    +
    +#
    +# ====================================================== MAIN PART OF PROGRAM
    +#
    +
    +if [[ ${S_uselocalwebsite} == "true" ||
    +	  ${S_useremotewebsite} == "true" ||
    +	  ${S_useremoteserver} == "true" ]]; then
    +	USE_SOMETHING="true"
    +else
    +	USE_SOMETHING="false"
    +fi
    +
    +# ======================================================================
    +# ================= Check for informational items.
    +
    +if [[ ${CHECK_INFORMATIONAL} == "true" ]]; then
    +
    +	# Settings used in this section.
    +	WEBSITES="$( whatWebsites )"
    +
    +	# Is Allsky set up to take dark frames?  This isn't done often, so if it is, inform the user.
    +	if [[ ${S_takedarkframes} == "true" ]]; then
    +		heading "Information"
    +		echo "${WSNs}${S_takedarkframes_label}${WSNe} is enabled."
    +		echo "FIX: Unset when you are done taking dark frames."
    +	fi
    +
    +	if [[ ${S_timelapsekeepsequence} == "true" ]]; then
    +		heading "Information"
    +		echo    "${WSNs}${S_timelapsekeepsequence_label}${WSNe} in enabled."
    +		echo -n "FIX: If you are not debugging timelapse videos consider disabling this"
    +		echo    " to save disk space."
    +	fi
    +
    +	if [[ ${S_thumbnailsizex} -ne 100 || ${S_thumbnailsizey} -ne 75 ]]; then
    +		heading "Information"
    +		echo "You are using a non-standard thumbnail size of ${S_thumbnailsizex}x${S_thumbnailsizey}."
    +		echo "Please note non-standard sizes have not been thoroughly tested and"
    +		echo "FIX: You may need to modify some code to get your thumbnail sizes working."
    +	fi
    +
    +	FOREVER="be kept forever or until you manually delete them"
    +	if [[ ${S_daystokeep} -eq 0 ]]; then
    +		heading "Information"
    +		echo -n "${WSNs}${S_daystokeep_label}${WSNe} is ${WSVs}0${WSVe}"
    +		echo    " which means images and videos will ${FOREVER}."
    +		echo    "FIX: If this is not what you want, change the setting."
    +	fi
    +
    +	if [[ (${WEBSITES} == "both" || ${WEBSITES} == "local") &&
    +			${S_daystokeeplocalwebsite} -eq 0 ]]; then
    +		heading "Information"
    +		echo -n "${WSNs}${S_daystokeeplocalwebsite_label}${WSNe} is ${WSVs}0${WSVe}"
    +		echo    " which means local web images and videos will ${FOREVER}."
    +		echo    "FIX: If this is not what you want, change the setting."
    +	fi
    +	# S_daystokeepremotewebsite may not be implemented; if so, ignore.
    +	if [[ (${WEBSITES} == "both" || ${WEBSITES} == "remote") &&
    +			-n ${S_daystokeepremotewebsite} && ${S_daystokeepremotewebsite} -eq 0 ]]; then
    +		heading "Information"
    +		echo -n "${WSNs}${S_daystokeepremotewebsite_label}${WSNe} is ${WSVs}0${WSVe}"
    +		echo    " which means remote web images and videos will ${FOREVER}."
    +		echo    "FIX: If this is not what you want, change the setting."
    +	fi
    +
    +	ERR="$( checkWidthHeight "Resize Image" "image" \
    +		"${S_imageresizewidth}" "${S_imageresizeheight}" \
    +	 	"${C_sensorWidth}" "${C_sensorHeight}" 2>&1 )"
    +	if [[ $? -ne 0 || -n ${ERR} ]]; then
    +		heading "Information"
    +		echo "${ERR}"
    +	fi
    +
    +	if [[ $((S_imagecroptop + S_imagecropright + S_imagecropbottom + S_imagecropleft)) -gt 0 ]]; then
    +		ERR="$( checkCropValues "${S_imagecroptop}" "${S_imagecropright}" \
    +				"${S_imagecropbottom}" "${S_imagecropleft}" \
    +				"${C_sensorWidth}" "${C_sensorHeight}" )"
    +		if [[ $? -ne 0 || -n ${ERR} ]]; then
    +			heading "Information"
    +			echo "${ERR}"
    +			echo "FIX: Check the ${WSNs}Image Crop Top/Right/Bottom/Left${WSNe} settings."
    +		fi
    +	fi
    +
    +	if [[ -n ${S_keogramextraparameters} ]]; then
    +		# These used to be set in the default S_keogramextraparameters.
    +		if echo "${S_keogramextraparameters}" |
    +				grep -E --silent "image-expand|-x|font-size|-S|font-line|-L|font-color|-C" ; then
    +			heading "Information"
    +			echo "${WSNs}${S_keogramextraparameters_label}${WSNe} contains one or more of:"
    +			echo "${SPACES}${WSVs}--image-expand${WSVe} or ${WSVs}-x${WSVe}"
    +			echo "${SPACES}${WSVs}--font-size${WSVe} or ${WSVs}-S${WSVe}"
    +			echo "${SPACES}${WSVs}--font-line${WSVe} or ${WSVs}-L${WSVe}"
    +			echo "${SPACES}${WSVs}--font-color${WSVe} or ${WSVs}-C${WSVe}"
    +			echo "FIX: These are now separate settings so move them to their own settings."
    +		fi
    +	fi
    +fi		# end of checking for informational items
    +
    +
    +
    +# ======================================================================
    +# ================= Check for warning items.
    +#	These are wrong and won't stop Allsky from running, but
    +#	may break part of Allsky, e.g., uploads may not work.
    +
    +if [[ ${CHECK_WARNINGS} == "true" ]]; then
    +
    +	if [[ -z ${S_lastchanged} ]]; then
    +		heading "Warning"
    +		echo "Allsky needs to be configured before it will run."
    +		echo "FIX: Go to the ${STRONGs}Allsky Settings${STRONGe} page in the WebUI."
    +	fi
    +
    +	if reboot_needed ; then
    +		heading "Warning"
    +		echo "The Pi needs to be rebooted before Allsky will start."
    +		echo "FIX: Reboot."
    +	fi
    +
    +	if [[ ${PI_OS} == "buster" ]]; then
    +		heading "Warning"
    +		echo "Your Pi is running the old 'Buster' operating system;"
    +		echo "this is the last release of Allsky that supports Buster."
    +		echo "FIX: Upgrade your Pi to Bookworm, 64-bit if possible."
    +	fi
    +
    +	check_delay "Daytime"
    +	check_delay "Nighttime"
    +
    +	if [[ ${CAMERA_TYPE} == "ZWO" ]]; then
    +		if [[ ${S_dayautoexposure} == "true" && ${S_dayautogain} == "true" ]]; then
    +			heading "Warning"
    +			echo -n "For ZWO cameras we suggest NOT using both '${S_dayautoexposure_label}'"
    +			echo    " and '${S_dayautogain_label}' at the same time."
    +			echo -n "FIX: Disable '${S_dayautogain_label}' and set '${S_daygain_label}' to"
    +			echo    " '${S_daymaxautogain_label}'."
    +		fi
    +		if [[ ${S_nightautoexposure} == "true" && ${S_nightautogain} == "true" ]]; then
    +			heading "Warning"
    +			echo -n "For ZWO cameras we suggest NOT using both '${S_nightautoexposure_label}'"
    +			echo    " and '${S_nightautogain_label}' at the same time."
    +			echo -n "FIX: Disable '${S_nightautogain_label}' and set '${S_nightgain_label}' to"
    +			echo    " '${S_nightmaxautogain_label}'."
    +		fi
    +	fi
    +
    +	##### Timelapse and mini-timelapse
    +	if [[ ${S_timelapsevcodec} == "libx264" ]]; then
    +		# Check if timelapse size is "too big" and will likely cause an error.
    +		# This is normally only an issue with the libx264 video codec which has
    +		# a dimension limit that we put in PIXEL_LIMIT.
    +		if [[ ${PI_OS} == "buster" ]]; then
    +			PIXEL_LIMIT=$((4096 * 2304))		# Limit of libx264
    +		else
    +			PIXEL_LIMIT=$((8192 * 4320))
    +		fi
    +
    +		function check_timelapse_size()
    +		{
    +			local RESIZED_WIDTH="${1}"		; local RESIZED_WIDTH_LABEL="${2}"
    +			local RESIZED_HEIGHT="${3}"		; local RESIZED_HEIGHT_LABEL="${4}"
    +			local W H
    +
    +			# Determine the final image size and put in ${W} and ${H}.
    +			# This is dependent on the these, in this order:
    +			#		if the images is resized, use that size
    +			#			else if the size is set in the WebUI (WIDTH, HEIGHT), use that size
    +			#				else use sensor size minus crop amount(s)
    +			if [[ ${RESIZED_WIDTH} -ne 0 ]]; then
    +				W="${RESIZED_WIDTH}"
    +			elif [[ ${S_width} -gt 0 ]]; then
    +				W="${S_width}"
    +			else
    +				W=$(( C_sensorWidth - S_imagecropleft - S_imagecropright ))
    +			fi
    +			if [[ ${RESIZED_HEIGHT} -ne 0 ]]; then
    +				H="${RESIZED_HEIGHT}"
    +			elif [[ ${S_height} -gt 0 ]]; then
    +				H="${S_height}"
    +			else
    +				H=$(( C_sensorWidth - S_imagecroptop - S_imagecropbottom ))
    +			fi
    +
    +			# W * H == total pixels in timelapse
    +			if [[ $(( W * H )) -gt ${PIXEL_LIMIT} ]]; then
    +				heading "Warning"
    +				echo -n "The ${WSNs}${RESIZED_WIDTH_LABEL}${WSNe} of ${WSVs}${W}${WSVe}"
    +				echo -n " and ${WSNs}${RESIZED_HEIGHT_LABEL}${WSNe} of ${WSVs}${H}${WSVe}"
    +				echo    " may cause errors while creating the video."
    +				echo -n "FIX: Consider either decreasing the video size or decreasing"
    +				echo    " each captured image via resizing and/or cropping."
    +			fi
    +		}
    +
    +	fi
    +
    +	# Check generate vs upload as well as the bitrate of a timelapse
    +	function check_timelapse_upload_and_bitrate()
    +	{
    +		local TYPE="${1}"				# type of timelapse
    +		local UPLOAD="${!2}"	; declare -n UPLOAD_LABEL="${2}_label"
    +		local BITRATE="${!3}"	; declare -n BITRATE_LABEL="${3}_label"
    +		local W="${!4}"			; declare -n W_LABEL="${4}_label"
    +		local H="${!5}"			; declare -n H_LABEL="${5}_label"
    +
    +		if [[ ${S_timelapsevcodec} == "libx264" ]]; then
    +			check_timelapse_size "${W}" "${W_LABEL}" "${H}" "${H_LABEL}"
    +		fi
    +
    +		# If the user doesn't have a Website or remote server there's nothing to upload
    +		# to so it's not an issue.
    +		if [[ ${UPLOAD} == "false" && ${USE_SOMETHING} == "true" ]]; then
    +			heading "Warning"
    +			echo -n "${TYPE} videos are being created"
    +			if [[ ${TYPE} == "Daily Timelapse" ]]; then
    +				echo -n " (${WSNs}${S_timelapsegenerate_label}${WSNe} = Yes)"
    +			else
    +				echo -n " (${WSNs}${S_minitimelapsenumimages_label}${WSNe} is greater than 0)"
    +:
    +			fi
    +			echo    " but not uploaded anywhere (${WSNs}${UPLOAD_LABEL}${WSNe} = No)"
    +			echo    "FIX: Either disable timelapse generation or (more likely) enable upload."
    +		fi
    +
    +		if [[ ${BITRATE: -1} == "k" ]]; then
    +			heading "Warning"
    +			echo "${WSNs}${BITRATE_LABEL}${WSNe} should be a number with OUT ${WSVs}k${WSVe}."
    +			echo "FIX: Remove the ${WSVs}k${WSVe} from the bitrate."
    +		fi
    +	}
    +
    +	# Timelapse
    +	if [[ ${S_timelapsegenerate} == "true" ]]; then	
    +		check_timelapse_upload_and_bitrate "Daily Timelapse" \
    +			"S_timelapseupload" "S_timelapsebitrate" \
    +			"S_timelapsewidth" "S_timelapseheight"
    +
    +	elif [[ ${S_timelapseupload} == "true" ]]; then
    +		heading "Warning"
    +		echo -n "Daily Timelapse videos are not being created"
    +		echo -n " (${WSNs}${S_timelapsegenerate_label}${WSNe} = No)"
    +		echo    " but ${WSNs}${S_timelapseupload_label}${WSNe} = Yes."
    +		echo    "FIX: Either create daily timelapse videos or disable upload."
    +	fi
    +
    +	# Mini-timelapse
    +	if [[ ${S_minitimelapsenumimages} -gt 0 ]]; then		# Generating mini-timelapse
    +		check_timelapse_upload_and_bitrate "Mini-Timelapse" \
    +			"S_minitimelapseupload" "S_minitimelapsebitrate" \
    +			"S_minitimelapsewidth" "S_minitimelapseheight"
    +
    +		# See if there's likely to be a problem with mini-timelapse creations
    +		# starting before the prior one finishes.
    +		# This is dependent on:
    +		#	1. Delay:		the delay between images: min(daydelay, nightdelay)
    +		#	2. Frequency:	how often mini-timelapse are created (i.e., after how many images)
    +		# 	3. NumImages:	how many images are used (the more the longer processing takes)
    +		# 	4. The speed of the Pi - this is the biggest unknown
    +
    +		# Minimum total time between start of timelapse creations.
    +		MIN_IMAGE_TIME_SEC=$(( MIN_IMAGE_TIME_MS / 1000 ))
    +		MIN_TIME_BETWEEN_TIMELAPSE_SEC=$(( S_minitimelapsefrequency * MIN_IMAGE_TIME_SEC ))
    +
    +		TYPICAL_T=50		# A guess at a "typical" number of images in a timelapse.
    +
    +		# On a Pi 4, creating a ${TYPICAL_T}-image timelapse takes
    +		#	- a few seconds on a small ZWO camera
    +		#	- about a minute with an RPi HQ camera
    +
    +		if [[ ${CAMERA_TYPE} == "ZWO" ]]; then
    +			S=3
    +		else
    +			S=60
    +		fi
    +		EXPECTED_TIME=$( echo "scale=0; (${S_minitimelapsenumimages} / ${TYPICAL_T}) * ${S}" | bc -l )
    +		if [[ ${EXPECTED_TIME} -gt ${MIN_TIME_BETWEEN_TIMELAPSE_SEC} ]]; then
    +			heading "Warning"
    +			echo -n "Your mini-timelapse settings may cause multiple timelapse to"
    +			echo    " be created simultaneously."
    +			echo    "FIX: Consider increasing the ${WSNs}Delay${WSNe} between pictures,"
    +			echo    "${SPACES}increasing ${WSNs}${S_minitimelapsefrequency_label}${WSNe},"
    +			echo    "${SPACES}decreasing ${WSNs}${S_minitimelapsenumimages_label}${WSNe},"
    +			echo    "${SPACES}or a combination of those changes."
    +			echo    "${SPACES}Expected time to create a mini-timelapse on a Pi 4 is"
    +			echo    "${SPACES}${EXPECTED_TIME} seconds but with your settings one could be created"
    +			echo    "${SPACES}as short as every ${MIN_TIME_BETWEEN_TIMELAPSE_SEC} seconds."
    +		fi
    +
    +	elif [[ ${S_minitimelapseupload} == "true" ]]; then
    +		heading "Warning"
    +		echo -n "Mini-timelapse videos are not being created"
    +		echo -n " (${WSNs}${S_minitimelapsenumimages_label}${WSNe} = 0)"
    +		echo    " but ${WSNs}${S_minitimelapseupload_label}${WSNe} = Yes"
    +		echo    "FIX: Either create videos or disable upload."
    +	fi
    +
    +	##### Keograms
    +	if [[ ${S_keogramgenerate} == "true" && ${S_keogramupload} == "false" && ${USE_SOMETHING} == "true" ]]; then
    +		heading "Warning"
    +		echo -n "Keograms are being created (${WSNs}${S_keogramgenerate_label}${WSNe} = Yes)"
    +		echo    " but not uploaded (${WSNs}${S_keogramupload_label}${WSNe} = No)"
    +		echo    "FIX: Either disable keogram generation or (more likely) enable upload."
    +	fi
    +	if [[ ${S_keogramgenerate} == "false" && ${S_keogramupload} == "true" ]]; then
    +		heading "Warning"
    +		echo -n "Keograms are not being created (${WSNs}${S_keogramgenerate_label}${WSNe} = No)"
    +		echo    " but ${WSNs}${S_keogramupload_label}${WSNe} = Yes"
    +		echo    "FIX: Either enable keogram generation or disable upload."
    +	fi
    +
    +	##### Startrails
    +	if [[ ${S_startrailsgenerate} == "true" && ${S_startrailsupload} == "false" && ${USE_SOMETHING} == "true" ]]; then
    +		heading "Warning"
    +		echo -n "Startrails are being created (${WSNs}${S_startrailsgenerate}${WSNe} = Yes)"
    +		echo    " but not uploaded (${WSNs}${S_startrailsupload_label}${WSNe} = No)"
    +		echo    "FIX: Either disable startrails generation or (more likely) enable upload."
    +	fi
    +	if [[ ${S_startrailsgenerate} == "false" && ${S_startrailsupload} == "true" ]]; then
    +		heading "Warning"
    +		echo -n "Startrails are not being created (${WSNs}${S_startrailsgenerate}${WSNe} = No)"
    +		echo    " but ${WSNs}${S_startrailsupload_label}${WSNe} = Yes"
    +		echo    "FIX: Either enable startrails generation or disable upload."
    +	fi
    +
    +	if is_number "${S_startrailsbrightnessthreshold}" ; then
    +		gawk -v x="${S_startrailsbrightnessthreshold}" ' BEGIN {
    +			if (x == 0.0) exit 0;
    +			else if (x == 1.0) exit 1;
    +			else if (x < 0.0 || x > 1.0) exit 2;
    +			exit 3;
    +			}'
    +		X=$?
    +	fi
    +	L="${S_startrailsbrightnessthreshold_label}"
    +	if [[ ${X} -eq 0 ]]; then
    +		heading "Warning"
    +		echo -n "${WSNs}${L}${WSNe} is 0.0 which means ALL images"
    +		echo    " will be IGNORED when creating startrails."
    +		echo    "FIX: Increase the value; start off at 0.1 and adjust if needed."
    +	elif [[ ${X} -eq 1 ]]; then
    +		heading "Warning"
    +		echo -n "${WSNs}${L}${WSNe} is 1.0 which means ALL images"
    +		echo    " will be USED when creating startrails, even daytime images."
    +		echo    "FIX: Increase the value; start off at 0.9 and adjust if needed."
    +	elif [[ ${X} -eq 2 ]]; then
    +		heading "Warning"
    +		echo -n "${WSNs}${L}${WSNe} is an invalid value:"
    +		echo    " ${WSVs}${S_startrailsbrightnessthreshold}${WSVe}."
    +		echo    "FIX: The value should be between 0.0 and 1.0."
    +	fi
    +
    +	##### Images
    +	if [[ ${S_takedaytimeimages} == "false" && ${S_savedaytimeimages} == "true" ]]; then
    +		heading "Warning"
    +		echo -n "${WSNs}${S_takedaytimeimages_label}${WSNe} of images is off"
    +		echo    " but ${WSNs}${S_savedaytimeimages_label}${WSNe} is on in the WebUI."
    +		echo    "FIX: Either enable capture or disable saving."
    +	fi
    +
    +	# These are floats which bash doesn't support, so use gawk to compare.
    +	if ! is_number "${S_imageremovebadlow}" || ! is_within_range "${S_imageremovebadlow}" ; then
    +		heading "Error"
    +		echo -n "${WSNs}${S_imageremovebadlow_label}${WSNe} (${S_imageremovebadlow})"
    +		echo    " must be 0.0 - 1.0, although it's normally around ${WSVs}0.1${WSVe}."
    +		echo    "FIX: Set to a vaild number.  ${WSVs}0${WSVe} disables the low threshold check."
    +	elif is_zero "${S_imageremovebadlow}" ; then
    +		heading "Warning"
    +		echo "${WSNs}${S_imageremovebadlow_label}${WSNe} is 0 (disabled)."
    +		echo "FIX: Set to a value greater than 0 unless you are debugging issues."
    +		echo "${SPACES}Try 0.1 and adjust if needed."
    +	fi
    +	if ! is_number "${S_imageremovebadhigh}" || ! is_within_range "${S_imageremovebadhigh}" ; then
    +		heading "Error"
    +		echo -n "${WSNs}${S_imageremovebadhigh_label}${WSNe} (${S_imageremovebadhigh})"
    +		echo    " must be 0.0 - 1.0, although it's normally around ${WSVs}0.9${WSVe}."
    +		echo    "FIX: Set to a valid number.  ${WSVs}0${WSVe} disables the high threshold check."
    +	elif is_zero "${S_imageremovebadhigh}" ; then
    +		heading "Warning"
    +		echo "${WSNs}${S_imageremovebadhigh_label}${WSNe} is 0 (disabled)."
    +		echo "FIX: Set to a value greater than 0 unless you are debugging issues."
    +		echo "${SPACES}Try 0.9 and adjust if needed."
    +	fi
    +
    +	##### Uploads
    +	if [[ ${S_imageresizeuploadswidth} -ne 0 || ${S_imageresizeuploadswidth} -ne 0 ]]; then
    +		if [[ ${S_imageuploadfrequency} -eq 0 ]]; then
    +			heading "Warning"
    +			echo -n "${WSNs}${S_imageresizeuploadswidth_label}${WSNe} and / or "
    +			echo -n "${WSNs}${S_imageresizeuploadsheight_label}${WSNe} are set"
    +			echo    " but you aren't uploading images (${WSNs}${S_imageuploadfrequency_label}${WSNe} = 0)."
    +			echo    "FIX: Either don't resize uploaded images or enable upload."
    +		fi
    +		if [[ ${S_imageresizeuploadswidth} -eq 0 && ${S_imageresizeuploadsheight} -ne 0 ]]; then
    +			heading "Warning"
    +			echo -n "${WSNs}${S_imageresizeuploadswidth_label}${WSNe} = 0"
    +			echo    " but ${WSNs}${S_imageresizeuploadsheight_label}${WSNe} is greater than 0."
    +			echo    "If one is set the other one must also be set."
    +			echo    "FIX: Set both to 0 to disable resizing uploads or both to some value."
    +		elif [[ ${S_imageresizeuploadswidth} -ne 0 && ${S_imageresizeuploadsheight} -eq 0 ]]; then
    +			heading "Warning"
    +			echo -n "${WSNs}${S_imageresizeuploadsheight_label}${WSNe} > 0"
    +			echo    " but ${WSNs}${S_imageresizeuploadswidth_label}${WSNe} = 0."
    +			echo    "If one is set the other one must also be set."
    +			echo    "FIX: Set both to 0 to disable resizing uploads or both to some value."
    +		fi
    +	fi
    +
    +	X="$( check_remote_server "REMOTEWEBSITE"  )"
    +	RET=$?
    +	if [[ ${RET} -eq 1 ]]; then
    +		heading "Warning"
    +		echo -e "${X}"
    +	elif [[ ${RET} -eq 2 ]]; then
    +		heading "Error"
    +		echo -e "${X}"
    +	fi
    +
    +	X="$( check_remote_server "REMOTESERVER" )"
    +	RET=$?
    +	if [[ ${RET} -eq 1 ]]; then
    +		heading "Warning"
    +		echo -e "${X}"
    +	elif [[ ${RET} -eq 2 ]]; then
    +		heading "Error"
    +		echo -e "${X}"
    +	fi
    +
    +fi		# end of checking for warning items
    +
    +
    +
    +# ======================================================================
    +# ================= Check for error items.
    +#	These are wrong and will likely keep Allsky from running.
    +
    +if [[ ${CHECK_ERRORS} == "true" ]]; then
    +
    +	##### Make sure it's a know camera type.
    +	if [[ ${CAMERA_TYPE} != "ZWO" && ${CAMERA_TYPE} != "RPi" ]]; then
    +		heading "Error"
    +		echo "INTERNAL ERROR: CAMERA_TYPE (${CAMERA_TYPE}) not valid."
    +		echo "Fix: Re-install Allsky."
    +	fi
    +
    +	##### Make sure the settings file is properly linked.
    +	if ! MSG="$( check_settings_link "${SETTINGS_FILE}" )" ; then
    +		heading "Error"
    +		echo -e "${MSG}"
    +	fi
    +
    +	function check_bool()
    +	{
    +		local B="${1}"
    +		local LABEL="${2}"
    +		local SETTING_NAME="${3}"
    +
    +		if [[ ${B,,} != "true" && ${B,,} != "false" ]]; then
    +			heading "Error"
    +			local L="${WSNs}${LABEL}${WSNe} (${SETTING_NAME})."
    +			echo "INTERNAL ERROR: ${L} must be either 'true' or 'false'."
    +			if [[ -z ${B} ]]; then
    +				echo "It is empty."
    +			else
    +				echo "It is '${B}'."
    +			fi
    +			echo "Fix: Re-install Allsky."
    +		fi
    +	}
    +
    +	##### Make sure these booleans have boolean values.
    +	for i in $( "${ALLSKY_SCRIPTS}/convertJSON.php" --type "boolean" )
    +	do
    +		declare -n v="S_${i}"
    +		declare -n l="S_${i}_label"
    +		check_bool "${v}" "${l}" "${i}"
    +	done
    +
    +	##### Make sure these numbers have numeric values.
    +	for i in \
    +		"type=an integer" \
    +		$( "${ALLSKY_SCRIPTS}/convertJSON.php" --type "integer" ) \
    +		"type=a float" \
    +		$( "${ALLSKY_SCRIPTS}/convertJSON.php" --type "float" )
    +	do
    +		if [[ ${i} =~ "type=" ]]; then
    +			T_="${i/type=/}"
    +			continue
    +		fi
    +		declare -n v="S_${i}"
    +		declare -n l="S_${i}_label"
    +		if ! is_number "${v}" ; then
    +			heading "Error"
    +			if [[ -z ${v} ]]; then
    +				v="empty"
    +			else
    +				v="${WSVs}${v}${WSVe}"
    +			fi
    +			echo "${WSNs}${l}${WSNe} (${i}) must be ${T_} number.  It is ${v}."
    +			echo "FIX: See the documenation for valid numbers."
    +		fi
    +	done
    +
    +	##### Check that all required settings are set.  All others are optional.
    +# TODO: determine from options.json file which are required.
    +	for i in angle latitude longitude locale
    +	do
    +		declare -n v="S_${i}"
    +		if [[ -z ${v} ]]; then
    +			heading "Error"
    +			declare -n l="S_${i}_label"
    +			echo "${l} must be set."
    +			echo "FIX: Set it in the WebUI then rerun ${ME}."
    +		fi
    +	done
    +
    +	##### Check that the required settings' values are valid.
    +	if [[ -n ${S_angle} ]] && ! is_number "${S_angle}" ; then
    +		heading "Error"
    +		echo "${WSNs}${S_angle_label}${WSNe} (${S_angle}) must be a number."
    +		echo "FIX: Set to a number in the WebUI then rerun ${ME}."
    +	fi
    +	if [[ -n ${S_latitude} ]] && ! LAT="$( convertLatLong "${S_latitude}" "latitude" 2>&1 )" ; then
    +		heading "Error"
    +		echo -e "${LAT}"		# ${LAT} contains the error message
    +		echo    "FIX: Correct the ${S_latitude_label} then rerun ${ME}."
    +	fi
    +	if [[ -n ${S_longitude} ]] && ! LONG="$( convertLatLong "${S_longitude}" "longitude" 2>&1 )" ; then
    +		heading "Error"
    +		echo -e "${LONG}"
    +		echo    "FIX: Correct the ${S_longitude_label} then rerun ${ME}."
    +	fi
    +
    +	##### Make sure required files exist
    +	f="${ALLSKY_WEBSITE_CONFIGURATION_FILE}"
    +	if [[ ${S_uselocalwebsite} == "true" && ! -f ${f} ]]; then
    +		heading "Error"
    +		echo "${WSNs}${S_uselocalwebsite_label}${WSNe} is enabled but '${f}' does not exist."
    +		echo "FIX: Either disable ${WSNs}${S_uselocalwebsite_label}${WSNe} or create '${f}."
    +	fi
    +	f="${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}"
    +	if [[ ${S_useremotewebsite} == "true" && ! -f ${f} ]]; then
    +		heading "Error"
    +		echo "${WSNs}${S_useremotewebsite_label}${WSNe} is enabled but '${f}' does not exist."
    +		echo "FIX: Either disable ${WSNs}${S_useremotewebsite_label}${WSNe} or run 'cd ~/allsky; ./remoteWebsiteInstall.sh'."
    +	fi
    +
    +	##### Check dark frames
    +	if [[ ${S_usedarkframes} == "true" ]]; then
    +		if [[ ! -d ${ALLSKY_DARKS} ]]; then
    +			heading "Error"
    +			echo -n "${WSNs}${S_usedarkframes_label}${WSNe} is set but the '${ALLSKY_DARKS}'"
    +			echo    " directory does not exist."
    +			echo    "FIX: Either disable the setting or take dark frames."
    +		else
    +			NUM_DARKS=$( find "${ALLSKY_DARKS}" -name "*.${EXTENSION}" 2>/dev/null | wc -l)
    +			if [[ ${NUM_DARKS} -eq 0 ]]; then
    +				heading "Error"
    +				echo -n "${WSNs}${S_usedarkframes_label}${WSNe} is set but there are no darks"
    +				echo    " in '${ALLSKY_DARKS}' with extension of '${EXTENSION}'."
    +				echo    "FIX: Either disable the setting or take dark frames."
    +			fi
    +		fi
    +	fi
    +
    +	##### Check for valid numbers.
    +	if ! is_number "${S_imageuploadfrequency}" || [[ ${S_imageuploadfrequency} -lt 0 ]]; then
    +		heading "Error"
    +		echo "${WSNs}${S_imageuploadfrequency_label}${WSNe} (${S_imageuploadfrequency}) must be 0 or greater."
    +		echo "FIX: Set to ${WSVs}0${WSVe} to disable image uploads or set to a positive number."
    +	fi
    +
    +	function check_stretch_numbers()
    +	{
    +		local TYPE="${1}"
    +		local LABEL_AMOUNT="${2}"
    +		local AMOUNT="${3}"
    +		local LABEL_MIDPOINT="${4}"
    +		local MIDPOINT="${5}"
    +
    +		if ! is_number "${AMOUNT}" || ! is_within_range "${AMOUNT}" 0 100 ; then
    +			heading "Error"
    +			echo "${WSNs}${TYPE} ${LABEL_AMOUNT}${WSNe} (${AMOUNT}) must be 0 - 100."
    +			echo "It is '${AMOUNT}'."
    +			echo "FIX: Set to a vaild number.  ${WSVs}0${WSVe} disables stretching."
    +		elif [[ ${MIDPOINT: -1} == "%" ]]; then
    +			heading "Error"
    +			echo -n "${WSNs}${TYPE} ${LABEL_MIDPOINT}${WSNe} (${MIDPOINT})"
    +			echo    " no longer accepts a ${WSVs}%${WSVe}."
    +			echo    "FIX: remove the ${WSVs}%${WSVe}."
    +		fi
    +	}
    +	check_stretch_numbers "Daytime" \
    +		"${S_imagestretchamountdaytime_label}" "${S_imagestretchamountdaytime}" \
    +		"${S_imagestretchmidpointdaytime_label}" "${S_imagestretchmidpointdaytime}"
    +	check_stretch_numbers "Nighttime" \
    +		"${S_imagestretchamountnighttime_label}" "${S_imagestretchamountnighttime}" \
    +		"${S_imagestretchmidpointnighttime_label}" "${S_imagestretchmidpointnighttime}"
    +
    +fi		# end of checking for error items
    +
    +
    +# ======================================================================
    +# ================= Summary (not displayed if called from WebUI)
    +NUM_FINDINGS=$((NUM_INFOS + NUM_WARNINGS + NUM_ERRORS))
    +
    +if [[ ${FROM_WEBUI} == "true" ]]; then
    +	RET=${NUM_FINDINGS}
    +else
    +	RET=0
    +	if [[ ${NUM_FINDINGS} -eq 0 ]]; then
    +		echo "No issues found."
    +	else
    +		echo
    +		heading "Summary"
    +		[[ ${NUM_INFOS} -gt 0 ]] && echo "Informational messages: ${NUM_INFOS}"
    +		[[ ${NUM_WARNINGS} -gt 0 ]] && echo "Warnings: ${NUM_WARNINGS}" && RET=1
    +		[[ ${NUM_ERRORS} -gt 0 ]] && echo "Errors: ${NUM_ERRORS}" && RET=2
    +	fi
    +fi
    +
    +exit ${RET}
    diff --git a/scripts/check_allsky.sh b/scripts/check_allsky.sh
    deleted file mode 100755
    index 7c65df806..000000000
    --- a/scripts/check_allsky.sh
    +++ /dev/null
    @@ -1,791 +0,0 @@
    -#!/bin/bash
    -
    -# Check the Allsky installation and settings for missing items,
    -# inconsistent items, illegal items, etc.
    -
    -# TODO: Within a heading, group by topic, e.g., all IMG_* together.
    -# TODO: Right now the checks within each heading are in the order I thought of them!
    -
    -# Allow this script to be executed manually, which requires several variables to be set.
    -[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$(realpath "$(dirname "${BASH_ARGV0}")/..")"
    -ME="$(basename "${BASH_ARGV0}")"
    -
    -#shellcheck disable=SC2086 source-path=.
    -source "${ALLSKY_HOME}/variables.sh"					|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086 source-path=scripts
    -source "${ALLSKY_SCRIPTS}/functions.sh" 				|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086 source-path=scripts
    -source "${ALLSKY_SCRIPTS}/installUpgradeFunctions.sh"	|| exit ${ALLSKY_ERROR_STOP}
    -
    -usage_and_exit()
    -{
    -	RET=${1}
    -	if [[ ${RET} == 0 ]]; then
    -		C="${YELLOW}"
    -	else
    -		C="${RED}"
    -	fi
    -	# Don't show the "--newer", "--no-check", or "--force-check" options since users
    -	# should never use them.
    -	echo
    -	echo -e "${C}Usage: ${ME} [--help] [--debug] [--no-check]${NC}"
    -	echo
    -	echo "'--help' displays this message and exits."
    -	echo
    -	# shellcheck disable=SC2086
    -	exit ${RET}
    -}
    -
    -# Check arguments
    -OK="true"
    -HELP="false"
    -DEBUG="false"
    -NEWER=""
    -FORCE_CHECK="true"
    -while [[ $# -gt 0 ]]; do
    -	ARG="${1}"
    -	case "${ARG}" in
    -		--help)
    -			HELP="true"
    -			;;
    -		--debug)
    -			DEBUG="true"
    -			;;
    -		--newer)
    -			NEWER="true"
    -			;;
    -		--no-check)
    -			FORCE_CHECK="false"
    -			;;
    -		--force-check)
    -			FORCE_CHECK="true"
    -			;;
    -		*)
    -			display_msg error "Unknown argument: '${ARG}'."
    -			OK="false"
    -			;;
    -	esac
    -	shift
    -done
    -[[ ${HELP} == "true" ]] && usage_and_exit 0
    -[[ ${OK} == "false" ]] && usage_and_exit 1
    -
    -#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/config.sh"	 					|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/ftp-settings.sh" 				|| exit ${ALLSKY_ERROR_STOP}
    -PROTOCOL="${PROTOCOL,,}"	# set to lowercase to make comparing easier
    -
    -BRANCH="$( get_branch "" )"
    -[[ -z ${BRANCH} ]] && BRANCH="${GITHUB_MAIN_BRANCH}"
    -[[ ${DEBUG} == "true" ]] && echo "DEBUG: using '${BRANCH}' branch."
    -
    -# Unless forced to, only do the version check if we're on the main branch,
    -# not on development branches, because when we're updating this script we
    -# don't want to have the updates overwritten from an older version on GitHub.
    -if [[ ${FORCE_CHECK} == "true" || ${BRANCH} == "${GITHUB_MAIN_BRANCH}" ]]; then
    -	CURRENT_SCRIPT="${ALLSKY_SCRIPTS}/${ME}"
    -	if [[ -n ${NEWER} ]]; then
    -		# This is a newer version
    -		echo "[${CURRENT_SCRIPT}] being replaced by newer version from GitHub."
    -		cp "${BASH_ARGV0}" "${CURRENT_SCRIPT}"
    -		chmod 775 "${CURRENT_SCRIPT}"
    -
    -	else
    -		# See if there's a newer version of this script; if so, download it and execute it.
    -		FILE_TO_CHECK="$(basename "${ALLSKY_SCRIPTS}")/${ME}"
    -		NEWER_SCRIPT="/tmp/${ME}"
    -		checkAndGetNewerFile --branch "${BRANCH}" "${CURRENT_SCRIPT}" "${FILE_TO_CHECK}" "${NEWER_SCRIPT}"
    -		RET=$?
    -		[[ ${RET} -eq 2 ]] && exit 2
    -		if [[ ${RET} -eq 1 ]]; then
    -			exec "${NEWER_SCRIPT}" --newer
    -			# Does not return
    -		fi
    -	fi
    -fi
    -
    -NUM_INFOS=0
    -NUM_WARNINGS=0
    -NUM_ERRORS=0
    -
    -function heading()
    -{
    -	local HEADER="${1}"
    -	local SUB_HEADER=""
    -	local DISPLAY_HEADER="false"
    -	case "${HEADER}" in
    -		Information)
    -			NUM_INFOS=$((NUM_INFOS + 1))
    -			if [[ $NUM_INFOS -eq 1 ]]; then
    -				DISPLAY_HEADER="true"
    -				SUB_HEADER=" (items that will not stop any part of Allsky from running)"
    -			fi
    -			;;
    -		Warnings)
    -			NUM_WARNINGS=$((NUM_WARNINGS + 1))
    -			if [[ $NUM_WARNINGS -eq 1 ]]; then
    -				DISPLAY_HEADER="true"
    -				SUB_HEADER=" (items that may keep parts of Allsky running)"
    -			fi
    -			;;
    -		Errors)
    -			NUM_ERRORS=$((NUM_ERRORS + 1))
    -			if [[ $NUM_ERRORS -eq 1 ]]; then
    -				DISPLAY_HEADER="true"
    -				SUB_HEADER=" (items that may keep Allsky from running)"
    -			fi
    -			;;
    -		Summary)
    -			DISPLAY_HEADER="true"
    -			;;
    -		*)
    -			echo "INTERNAL ERROR in heading(): Unknown HEADER '${HEADER}'."
    -			;;
    -	esac
    -
    -	if [[ ${DISPLAY_HEADER} == "true" ]]; then
    -		echo -e "\n---------- ${HEADER}${SUB_HEADER} ----------\n"
    -	else
    -		echo "-----"	# Separates lines within a header group
    -	fi
    -}
    -
    -# Determine if the specified value is a number.
    -function is_number()
    -{
    -	local VALUE="${1}"
    -	[[ -z ${VALUE} ]] && return 1
    -	shopt -s extglob
    -	local NON_NUMERIC="${VALUE/?([-+])*([0-9])?(.)*([0-9])/}"
    -	if [[ -z ${NON_NUMERIC} ]]; then
    -		# Nothing but +, -, 0-9, .
    -		return 0
    -	else
    -		# Has non-numeric character
    -		return 1
    -	fi
    -}
    -
    -# Return the min of two numbers.
    -function min() {
    -	local ONE="${1}"
    -	local TWO="${2}"
    -	if [[ ${ONE} -lt ${TWO} ]]; then
    -		echo "${ONE}"
    -	else
    -		echo "${TWO}"
    -	fi
    -}
    -
    -# =================================================== CHECKING FUNCTIONS
    -
    -# The various upload protocols need different variables defined.
    -# For the specified protocol, make sure the specified variable is defined.
    -function check_PROTOCOL()
    -{
    -	P="${1}"	# Protocol
    -	V="${2}"	# Variable
    -	if [[ -z ${!V} ]]; then
    -		heading "Warnings"
    -		echo "PROTOCOL (${P}) set but not '${V}'."
    -		echo "Uploads will not work."
    -		return 1
    -	fi
    -	return 0
    -}
    -
    -# Check that when a variable holds a location, the location exists.
    -function check_exists() {
    -	local VALUE="${!1}"
    -	if [[ ${VALUE:0:1} == "~" ]]; then
    -		VALUE="${HOME}${VALUE:1}"
    -	fi
    -	if [[ -n ${VALUE} && ! -e ${VALUE} ]]; then
    -		heading "Warnings"
    -		echo "${1} is set to '${VALUE}' but it does not exist."
    -	fi
    -}
    -
    -
    -
    -DAYDELAY_MS=$(settings .daydelay) || echo "Problem getting .daydelay"
    -NIGHTDELAY_MS=$(settings .nightdelay) || echo "Problem getting .nightdelay"
    -
    -	# Use min() for worst case.
    -MIN_DELAY_MS=$( min "${DAYDELAY_MS}" "${NIGHTDELAY_MS}" )
    -	# This is typically the max daytime exposure, which is shorter than nighttime so use it.
    -MIN_EXPOSURE_MS=250
    -	# Minimum total time spent on each image
    -MIN_IMAGE_TIME_MS=$((MIN_EXPOSURE_MS + MIN_DELAY_MS))
    -
    -##### Check if the delay is so short it's likely to cause problems.
    -function check_delay()
    -{
    -# TODO: use the module average flow times for day and night
    -
    -	# With the legacy overlay method it might take up to a couple seconds to save an image.
    -	# With the module method it can take up to 5 seconds.
    -	local OVERLAY_METHOD=$(settings .overlayMethod) || echo "Problem getting .overlayMethod." >&2
    -	if [[ ${OVERLAY_METHOD} -eq 1 ]]; then
    -		MAX_TIME_TO_SAVE_MS=5000
    -	else
    -		MAX_TIME_TO_SAVE_MS=2000
    -	fi
    -	if [[ ${MAX_TIME_TO_SAVE_MS} -gt ${MIN_IMAGE_TIME_MS} ]]; then
    -		heading "Warnings"
    -		echo "The minimum delay of ${MIN_DELAY_MS} ms may be too short"
    -		echo "given the maximum expected time to save and process"
    -		echo "an image (${MAX_TIME_TO_SAVE_MS} ms)."
    -		echo "A new image may appear before the prior one has finished processing."
    -		echo "Consider increasing your delay."
    -	fi
    -}
    -
    -#
    -# ====================================================== MAIN PART OF PROGRAM
    -#
    -
    -# Variables used below.
    -TAKING_DARKS="$(settings .takeDarkFrames)" || echo "Problem getting .takeDarkFrames." >&2
    -# per the WebUI, width and height are usually 0
    -WIDTH="$(settings .width)" || echo "Problem getting .width." >&2
    -HEIGHT="$(settings .height)" || echo "Problem getting .height." >&2
    -# physical sensor size
    -SENSOR_WIDTH="$(settings .sensorWidth "${CC_FILE}")" || echo "Problem getting .sensorWidth." >&2
    -SENSOR_HEIGHT="$(settings .sensorHeight "${CC_FILE}")" || echo "Problem getting .sensorHeight." >&2
    -TAKE="$(settings .takeDaytimeImages)" || echo "Problem getting .takeDaytimeImages." >&2
    -SAVE="$(settings .saveDaytimeImages)" || echo "Problem getting .saveDaytimeImages." >&2
    -ANGLE="$(settings .angle)" || echo "Problem getting .angle" >&2
    -LATITUDE="$(settings .latitude)" || echo "Problem getting .latitude." >&2
    -LONGITUDE="$(settings .longitude)" || echo "Problem getting .longitude" >&2
    -# shellcheck disable=SC2034
    -LOCALE="$(settings .locale)" || echo "Problem getting .locale" >&2
    -USING_DARKS="$(settings .useDarkFrames)" || echo "Problem getting .useDarkFrames" >&2
    -WEBSITES="$(whatWebsites)"
    -
    -# ======================================================================
    -# ================= Check for informational items.
    -#	There is nothing wrong with these, it's just that they typically don't exist.
    -
    -# Is Allsky set up to take dark frames?  This isn't done often, so if it is, inform the user.
    -if [[ ${TAKING_DARKS} -eq 1 ]]; then
    -	heading "Information"
    -	echo "'Take Dark Frames' is set."
    -	echo "Unset when you are done taking dark frames."
    -fi
    -
    -if [[ ${KEEP_SEQUENCE} == "true" ]]; then
    -	heading "Information"
    -	echo "KEEP_SEQUENCE in config.sh is 'true'."
    -	echo "If you are not testing / debugging timelapse videos consider changing this to 'false'"
    -	echo "to save disk space."
    -fi
    -
    -if [[ ${THUMBNAIL_SIZE_X} -ne 100 || ${THUMBNAIL_SIZE_Y} -ne 75 ]]; then
    -	heading "Information"
    -	echo -n "You are using a non-standard thumbnail size"
    -	echo " (${THUMBNAIL_SIZE_X} x ${THUMBNAIL_SIZE_Y}) in config.sh."
    -	echo -e "\tPlease note non-standard sizes have not been thoroughly tested and"
    -	echo -e "\tyou will likely need to modify some code to get them working."
    -fi
    -
    -DAYS_TO_KEEP=${DAYS_TO_KEEP:-0}				# old versions allowed "" to disable
    -if [[ ${DAYS_TO_KEEP} -eq 0 ]]; then
    -	heading "Information"
    -	echo "DAYS_TO_KEEP is 0 which means images and videos will be kept forever"
    -	echo -e "\tor until you manually delete them."
    -fi
    -
    -WEB_DAYS_TO_KEEP=${WEB_DAYS_TO_KEEP:-0}		# old versions allowed "" to disable
    -if [[ ${WEB_DAYS_TO_KEEP} -eq 0 ]]; then
    -	if [[ ${WEBSITES} == "both" || ${WEBSITES} == "remote" ]]; then
    -		heading "Information"
    -		echo "WEB_DAYS_TO_KEEP is 0 which means local web images and videos will be kept forever"
    -		echo -e "\tor until you manually delete them."
    -	fi
    -else	# -gt 0
    -	if [[ ${WEBSITES} == "none" || ${WEBSITES} == "remote" ]]; then
    -		heading "Information"
    -		echo "WEB_DAYS_TO_KEEP is set to ${WEB_DAYS_TO_KEEP} but there is no local Website."
    -		echo -e "\tSet 'WEB_DAYS_TO_KEEP=0' in config.sh to keep this message from appearing."
    -		if [[ ${WEBSITES} == "remote" ]]; then
    -			echo -e "\tWEB_DAYS_TO_KEEP only works with LOCAL websites, not REMOTE."
    -		fi
    -	fi
    -fi
    -
    -if [[ ${IMG_RESIZE} == "true" && ${SENSOR_WIDTH} == "${IMG_WIDTH}" && ${SENSOR_HEIGHT} == "${IMG_HEIGHT}" ]]; then
    -	heading "Information"
    -	echo "Images will be resized to the same size as the sensor; this does nothing useful."
    -	echo "Check IMG_RESIZE, IMG_WIDTH (${IMG_WIDTH}), and IMG_HEIGHT (${IMG_HEIGHT})."
    -fi
    -#shellcheck disable=SC2153		# it thinks CROP_HEIGHT may be misspelled
    -if [[ ${CROP_IMAGE} == "true" && ${SENSOR_WIDTH} == "${CROP_WIDTH}" && ${SENSOR_HEIGHT} == "${CROP_HEIGHT}" ]]; then
    -	heading "Information"
    -	echo "Images will be cropped to the same size as the sensor; this does nothing useful."
    -	echo "Check CROP_IMAGE, CROP_WIDTH (${CROP_WIDTH}), and CROP_HEIGHT (${CROP_HEIGHT})."
    -fi
    -
    -LAST_CHANGED="$( settings ".lastChanged" )" || echo "Problem getting .lastChanged" >&2
    -if [[ ${LAST_CHANGED} == "" || ${LAST_CHANGED} == "null" ]]; then
    -	heading "Information"
    -	echo "Allsky needs to be configured before it will run."
    -	echo "See the 'Allsky Settings' page in the WebUI."
    -fi
    -
    -if reboot_needed ; then
    -	heading "Information"
    -	echo "The Pi needs to be rebooted before Allsky will start."
    -fi
    -
    -# ======================================================================
    -# ================= Check for warning items.
    -#	These are wrong and won't stop Allsky from running, but
    -#	may break part of Allsky, e.g., uploads may not work.
    -
    -##### Check if the delay is so short it's likely to cause problems.
    -check_delay
    -
    -
    -##### Check if timelapse size is "too big" and will likely cause an error.
    -# This is normally only an issue with the libx264 video codec which has a dimension limit
    -# that we put in PIXEL_LIMIT
    -if [[ ${VCODEC} == "libx264" ]]; then
    -	PIXEL_LIMIT=$((4096 * 2304))
    -	function check_timelapse_size()
    -	{
    -		local TYPE="${1}"			# type of video
    -		local V_WIDTH="${2}"		# video width
    -		local W_WIDTH="${3}"		# width per the WebUI, adjusted for if it's 0
    -		local V_HEIGHT="${4}"
    -		local W_HEIGHT="${5}"
    -
    -		if [[ ${V_WIDTH} -eq 0 ]]; then
    -			W="${W_WIDTH}"
    -		else
    -			W="${V_WIDTH}"
    -		fi
    -		if [[ ${V_HEIGHT} -eq 0 ]]; then
    -			H="${W_HEIGHT}"
    -		else
    -			H="${V_HEIGHT}"
    -		fi
    -		TIMELAPSE_PIXELS=$(( W * H ))
    -		if [[ ${TIMELAPSE_PIXELS} -gt ${PIXEL_LIMIT} ]]; then
    -			heading "Warnings"
    -			echo "The ${TYPE} width (${W}) and height (${H}) may cause errors while creating the video."
    -			echo "Consider either decreasing the video size via TIMELAPSEWIDTH and TIMELAPSEHEIGHT"
    -			echo "or decrease each captured image via the WebUI and/or IMG_RESIZE and/or CROP_IMAGE."
    -		fi
    -	}
    -
    -	# Determine the final image size.
    -	# This is dependent on the these, in this order:
    -	#	if: CROP_IMAGE=true (CROP_WIDTH, CROP_HEIGHT) use it.
    -	#		else if:  IMG_RESIZE=true (IMG_WIDTH, IMG_HEIGHT), use it
    -	#			else if: size set in WebUI (width, height), use it
    -	#				else use sensor size
    -
    -	if [[ ${CROP_IMAGE} == "true" ]]; then
    -		W="${CROP_WIDTH}"
    -		H="${CROP_HEIGHT}"
    -	elif [[ ${IMG_RESIZE} == "true" ]]; then
    -		W="${IMG_WIDTH}"
    -		H="${IMG_HEIGHT}"
    -	else
    -		if [[ ${WIDTH} -gt 0 ]]; then
    -			W="${WIDTH}"
    -		else
    -			W="${SENSOR_WIDTH}"
    -		fi
    -		if [[ ${HEIGHT} -gt 0 ]]; then
    -			H="${HEIGHT}"
    -		else
    -			H="${SENSOR_HEIGHT}"
    -		fi
    -	fi
    -
    -	if [[ ${TIMELAPSE} == "true" ]]; then
    -		check_timelapse_size "timelapse" "${TIMELAPSEWIDTH}" "${W}" "${TIMELAPSEHEIGHT}" "${H}"
    -	fi
    -	if [[ ${TIMELAPSE_MINI_IMAGES} -gt 0 ]]; then
    -		check_timelapse_size "mini timelapse" "${TIMELAPSE_MINI_WIDTH}" "${W}" "${TIMELAPSE_MINI_HEIGHT}" "${H}"
    -	fi
    -fi
    -
    -##### Timelapse and mini timelapse
    -if [[ ${TIMELAPSE} == "true" && ${UPLOAD_VIDEO} == "false" ]]; then
    -	heading "Warnings"
    -	echo "Timelapse videos are being created (TIMELAPSE='true') but not uploaded (UPLOAD_VIDEO='false')"
    -fi
    -if [[ ${TIMELAPSE} == "false" && ${UPLOAD_VIDEO} == "true" ]]; then
    -	heading "Warnings"
    -	echo "Timelapse videos are not being created (TIMELAPSE='false') but UPLOAD_VIDEO='true'"
    -fi
    -
    -
    -if [[ ${TIMELAPSE_MINI_IMAGES} -gt 0 ]]; then
    -
    -	# See if there's likely to be a problem with mini timelapse creations
    -	# starting before the prior one finishes.
    -	# This is dependent on:
    -	#	1. Delay:		the delay between images: min(daytime_delay, nighttime_delay)
    -	#	2. Frequency:	how often mini timelapse are created (i.e., after how many images)
    -	# 	3. NumImages:	how many images are used (the more the longer processing takes)
    -	# 	4. the speed of the Pi - this is the biggest unknown
    -	function get_exposure() {	# return the time spent on one image, prior to delay
    -		local TIME="${1}"
    -		if [[ $(settings ".${TIME}autoexposure") -eq 1 ]]; then
    -			settings ".${TIME}maxautoexposure" || echo "Problem getting .${TIME}maxautoexposure." >&2
    -		else
    -			settings ".${TIME}exposure" || echo "Problem getting .${TIME}exposure." >&2
    -		fi
    -	}
    -
    -	# Minimum total time between start of timelapse creations.
    -	MIN_IMAGE_TIME_SEC=$(( MIN_IMAGE_TIME_MS / 1000))
    -	MIN_TIME_BETWEEN_TIMELAPSE_SEC=$(echo "scale=0; ${TIMELAPSE_MINI_FREQUENCY} * ${MIN_IMAGE_TIME_SEC}" | bc -l)
    -	MIN_TIME_BETWEEN_TIMELAPSE_SEC=${MIN_TIME_BETWEEN_TIMELAPSE_SEC/.*/}
    -
    -if false; then		# for testing
    -	echo "CONSISTENT_DELAYS=${CONSISTENT_DELAYS}"
    -	echo "MIN_IMAGE_TIME_SEC=${MIN_IMAGE_TIME_SEC}"
    -	echo "MIN_TIME_BETWEEN_TIMELAPSE_SEC=${MIN_TIME_BETWEEN_TIMELAPSE_SEC}"
    -	echo "TIMELAPSE_MINI_IMAGES=${TIMELAPSE_MINI_IMAGES}"
    -	echo "CAMERA_TYPE=${CAMERA_TYPE}"
    -	TIMELAPSE_MINI_IMAGES=120
    -fi
    -
    -	# On a Pi 4, creating a 50 image timelapse takes
    -	#	- a few seconds on a small ZWO camera
    -	#	- about a minute with an RPi HQ
    -
    -	if [[ ${CAMERA_TYPE} == "ZWO" ]]; then
    -		S=3
    -	else
    -		S=60
    -	fi
    -	EXPECTED_TIME=$(echo "scale=0; (${TIMELAPSE_MINI_IMAGES} / 50) * ${S}" | bc -l)
    -	if [[ ${EXPECTED_TIME} -gt ${MIN_TIME_BETWEEN_TIMELAPSE_SEC} ]]; then
    -		heading "Warnings"
    -		echo "Your mini timelapse settings may cause multiple timelapse to be created simultaneously."
    -		echo "Consider increasing DELAY between pictures, increasing TIMELAPSE_MINI_FREQUENCY,"
    -		echo "decrease TIMELAPSE_MINI_IMAGES, or a combination of those changes."
    -		echo "Expected time to create a mini timelapse on a Pi 4 is ${EXPECTED_TIME} seconds"
    -		echo "but with your settings one will be created as short as every ${MIN_TIME_BETWEEN_TIMELAPSE_SEC} seconds."
    -	fi
    -fi
    -
    -##### Keograms
    -if [[ ${KEOGRAM} == "true" && ${UPLOAD_KEOGRAM} == "false" ]]; then
    -	heading "Warnings"
    -	echo "Keograms are being created (KEOGRAM='true') but not uploaded (UPLOAD_KEOGRAM='false')"
    -fi
    -if [[ ${KEOGRAM} == "false" && ${UPLOAD_KEOGRAM} == "true" ]]; then
    -	heading "Warnings"
    -	echo "Keograms are not being created (KEOGRAM='false') but UPLOAD_KEOGRAM='true'"
    -fi
    -
    -##### Startrails
    -if [[ ${STARTRAILS} == "true" && ${UPLOAD_STARTRAILS} == "false" ]]; then
    -	heading "Warnings"
    -	echo "Startrails are being created (STARTRAILS='true') but not uploaded (UPLOAD_STARTRAILS='false')"
    -fi
    -if [[ ${STARTRAILS} == "false" && ${UPLOAD_STARTRAILS} == "true" ]]; then
    -	heading "Warnings"
    -	echo "Startrails are not being created (STARTRAILS='false') but UPLOAD_STARTRAILS='true'"
    -fi
    -
    -if [[ ${BRIGHTNESS_THRESHOLD} == "0.0" ]]; then
    -	heading "Warnings"
    -	echo "BRIGHTNESS_THRESHOLD is 0.0 which means ALL images will be IGNORED when creating startrails."
    -elif [[ ${BRIGHTNESS_THRESHOLD} == "1.0" ]]; then
    -	heading "Warnings"
    -	echo "BRIGHTNESS_THRESHOLD is 1.0 which means ALL images will be USED when creating startrails, even daytime images."
    -fi
    -
    -##### Images
    -
    -if [[ ${TAKE} -eq 0 && ${SAVE} -eq 1 ]]; then
    -	heading "Warnings"
    -	echo "'Daytime Capture' is off but 'Daytime Save' is on in the WebUI."
    -fi
    -
    -if [[ ${REMOVE_BAD_IMAGES} != "true" ]]; then
    -	heading "Warnings"
    -	echo "REMOVE_BAD_IMAGES is not 'true'."
    -	echo We HIGHLY recommend setting it to 'true' unless you are debugging issues.
    -fi
    -
    -##### Uploads
    -if [[ ${RESIZE_UPLOADS} == "true" && ${IMG_UPLOAD} == "false" ]]; then
    -	heading "Warnings"
    -	echo "RESIZE_UPLOADS is 'true' but you aren't uploading images (IMG_UPLOAD='false')."
    -fi
    -
    -case "${PROTOCOL}" in
    -	"" | local)		# Nothing needed for these
    -		;;
    -
    -	ftp | ftps | sftp)
    -		check_PROTOCOL "${PROTOCOL}" "REMOTE_HOST"
    -		check_PROTOCOL "${PROTOCOL}" "REMOTE_USER"
    -		check_PROTOCOL "${PROTOCOL}" "REMOTE_PASSWORD"
    -		if [[ ${PROTOCOL} == "ftp" ]]; then
    -			heading "Warnings"
    -			echo "PROTOCOL set to insecure 'ftp'.  Try to use 'ftps' or 'sftp' instead."
    -		fi
    -		;;
    -
    -	scp)
    -		check_PROTOCOL "${PROTOCOL}" "REMOTE_HOST"
    -		if check_PROTOCOL "${PROTOCOL}" "SSH_KEY_FILE" && [[ ! -e ${SSH_KEY_FILE} ]]; then
    -			heading "Warnings"
    -			echo "PROTOCOL (${PROTOCOL}) set but 'SSH_KEY_FILE' (${SSH_KEY_FILE}) does not exist."
    -			echo "Uploads will not work."
    -		fi
    -		;;
    -
    -	s3)
    -		if check_PROTOCOL "${PROTOCOL}" "AWS_CLI_DIR" && [[ ! -e ${AWS_CLI_DIR} ]]; then
    -			heading "Warnings"
    -			echo "PROTOCOL (${PROTOCOL}) set but 'AWS_CLI_DIR' (${AWS_CLI_DIR}) does not exist."
    -			echo "Uploads will not work."
    -		fi
    -		check_PROTOCOL "${PROTOCOL}" "S3_BUCKET"
    -		check_PROTOCOL "${PROTOCOL}" "S3_ACL"
    -		;;
    -
    -	gcs)
    -		check_PROTOCOL "${PROTOCOL}" "GCS_BUCKET"
    -		check_PROTOCOL "${PROTOCOL}" "GCS_ACL"
    -		;;
    -
    -	*)
    -		heading "Warnings"
    -		echo "PROTOCOL (${PROTOCOL}) not blank or one of: local, ftp, ftps, sftp, scp, s3, gcs."
    -		echo "Uploads will not work until this is corrected."
    -		;;
    -esac
    -
    -if [[ -n ${REMOTE_PORT} ]] && ! is_number "${REMOTE_PORT}" ; then
    -	heading "Warnings"
    -	echo "REMOTE_PORT (${REMOTE_PORT}) must be a number."
    -	echo "Uploads will not work until this is corrected."
    -fi
    -
    -##### If these variables are set, their corresponding directory should exist.
    -check_exists "WEB_IMAGE_DIR"
    -check_exists "WEB_VIDEOS_DIR"
    -check_exists "WEB_KEOGRAM_DIR"
    -check_exists "WEB_STARTRAILS_DIR"
    -check_exists "UHUBCTL_PATH"
    -
    -##### Check for Allsky Website-related issues.
    -if [[ -f ${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE} && (${PROTOCOL} == "" || ${PROTOCOL} == "local") ]]; then
    -	heading "Warnings"
    -	echo "A remote Allsky Website configuration file was found but PROTOCOL doesn't support uploading files."
    -fi
    -
    -
    -
    -# ======================================================================
    -# ================= Check for error items.
    -#	These are wrong and will likely keep Allsky from running.
    -
    -##### Make sure it's a know camera type.
    -if [[ ${CAMERA_TYPE} != "ZWO" && ${CAMERA_TYPE} != "RPi" ]]; then
    -	heading "Errors"
    -	echo "INTERNAL ERROR: CAMERA_TYPE (${CAMERA_TYPE}) not valid."
    -fi
    -
    -##### Make sure the settings file is properly linked.
    -if ! MSG="$( check_settings_link "${SETTINGS_FILE}" )" ; then
    -	heading "Errors"
    -	echo -e "${MSG}"
    -fi
    -
    -##### Make sure these booleans have boolean values, or are blank.
    -for i in IMG_UPLOAD IMG_UPLOAD_ORIGINAL_NAME IMG_RESIZE CROP_IMAGE AUTO_STRETCH \
    -	RESIZE_UPLOADS IMG_CREATE_THUMBNAILS REMOVE_BAD_IMAGES TIMELAPSE UPLOAD_VIDEO \
    -	TIMELAPSE_UPLOAD_THUMBNAIL TIMELAPSE_MINI_FORCE_CREATION TIMELAPSE_MINI_UPLOAD_VIDEO \
    -	TIMELAPSE_MINI_UPLOAD_THUMBNAIL KEOGRAM UPLOAD_KEOGRAM \
    -	STARTRAILS UPLOAD_STARTRAILS POST_END_OF_NIGHT_DATA
    -do
    -	if [[ -n ${!i} && ${!i,,} != "true" && ${!i,,} != "false" ]]; then
    -		heading "Errors"
    -		echo "${i} must be either 'true' or 'false'; it is '${!i}'."
    -	fi
    -done
    -
    -##### Check that all required settings are set.  All others are optional.
    -# TODO: determine from options.json file which are required.
    -for i in ANGLE LATITUDE LONGITUDE LOCALE
    -do
    -	if [[ -z ${!i} || ${!i} == "null" ]]; then
    -		heading "Errors"
    -		echo "${i} must be set."
    -	fi
    -done
    -
    -##### Check that the required settings' values are valid.
    -if [[ -n ${ANGLE} ]] && ! is_number "${ANGLE}" ; then
    -	heading "Errors"
    -	echo "ANGLE (${ANGLE}) must be a number."
    -fi
    -if [[ -n ${LATITUDE} ]]; then
    -	if ! LAT="$(convertLatLong "${LATITUDE}" "latitude" 2>&1)" ; then
    -		heading "Errors"
    -		echo -e "${LAT}"		# ${LAT} contains the error message
    -	fi
    -fi
    -if [[ -n ${LONGITUDE} ]]; then
    -	if ! LONG="$(convertLatLong "${LONGITUDE}" "longitude" 2>&1)" ; then
    -		heading "Errors"
    -		echo -e "${LONG}"
    -	fi
    -fi
    -
    -##### Check dark frames
    -if [[ ${USING_DARKS} -eq 1 ]]; then
    -	if [[ ! -d ${ALLSKY_DARKS} ]]; then
    -		heading "Errors"
    -		echo "'Use Dark Frames' is set but the '${ALLSKY_DARKS}' directory does not exist."
    -	else
    -		NUM_DARKS=$(find "${ALLSKY_DARKS}" -name "*.${EXTENSION}" 2>/dev/null | wc -l)
    -		if [[ ${NUM_DARKS} -eq 0 ]]; then
    -			heading "Errors"
    -			echo -n "'Use Dark Frames' is set but there are no darks"
    -			echo " in '${ALLSKY_DARKS}' with extension of '${EXTENSION}'."
    -		fi
    -	fi
    -fi
    -
    -##### Check for valid numbers.
    -if ! is_number "${IMG_UPLOAD_FREQUENCY}" || [[ ${IMG_UPLOAD_FREQUENCY} -le 0 ]]; then
    -	heading "Errors"
    -	echo "IMG_UPLOAD_FREQUENCY (${IMG_UPLOAD_FREQUENCY}) must be 1 or greater."
    -fi
    -if [[ ${AUTO_STRETCH} == "true" ]]; then
    -	if ! is_number "${AUTO_STRETCH_AMOUNT}" || \
    -			[[ ${AUTO_STRETCH_AMOUNT} -le 0 ]] || \
    -			[[ ${AUTO_STRETCH_AMOUNT} -gt 100 ]] ; then
    -		heading "Errors"
    -		echo "AUTO_STRETCH_AMOUNT (${AUTO_STRETCH_AMOUNT}) must be 1 - 100."
    -	fi
    -	if ! echo "${AUTO_STRETCH_MID_POINT}" | grep --silent "%" ; then
    -		heading "Errors"
    -		echo "AUTO_STRETCH_MID_POINT (${AUTO_STRETCH_MID_POINT}) must be an integer percent,"
    -		echo "for example:  10%."
    -	fi
    -fi
    -if ! is_number "${BRIGHTNESS_THRESHOLD}" || \
    -		! echo "${BRIGHTNESS_THRESHOLD}" | \
    -		awk '{if ($1 < 0.0 || $1 > 1.0) exit 1; exit 0; }' ; then
    -	heading "Errors"
    -	echo "BRIGHTNESS_THRESHOLD (${BRIGHTNESS_THRESHOLD}) must be 0.0 - 1.0"
    -fi
    -if [[ ${REMOVE_BAD_IMAGES} == "true" ]]; then
    -	if ! is_number "${REMOVE_BAD_IMAGES_THRESHOLD_LOW}" || \
    -		! echo "${REMOVE_BAD_IMAGES_THRESHOLD_LOW}" | \
    -		awk '{if ($1 < 0.0) exit 1; exit 0; }' ; then
    -		heading "Errors"
    -		echo "REMOVE_BAD_IMAGES_THRESHOLD_LOW (${REMOVE_BAD_IMAGES_THRESHOLD_LOW}) must be 0 - 100.0,"
    -		echo "although it's normally around 0.5.  0 disables the low threshold check."
    -	fi
    -	if ! is_number "${REMOVE_BAD_IMAGES_THRESHOLD_HIGH}" || \
    -		! echo "${REMOVE_BAD_IMAGES_THRESHOLD_HIGH}" | \
    -		awk '{if ($1 < 0.0) exit 1; exit 0; }' ; then
    -		heading "Errors"
    -		echo "REMOVE_BAD_IMAGES_THRESHOLD_HIGH (${REMOVE_BAD_IMAGES_THRESHOLD_HIGH}) must be 0 - 100.0,"
    -		echo "although it's normally around 90.  0 disables the high threshold check."
    -	fi
    -fi
    -
    -##### If images are being resized or cropped,
    -# make sure the resized/cropped image is fully within the sensor image.
    -HAS_PIXEL_ERROR="false"
    -if [[ ${IMG_RESIZE} == "true" ]]; then
    -	if ! X="$(checkPixelValue "IMG_WIDTH" "${IMG_WIDTH}" "width" "${SENSOR_WIDTH}")" ; then
    -		heading "Errors"
    -		echo -e "${X}"
    -		HAS_PIXEL_ERROR="true"
    -	fi
    -	if ! X="$(checkPixelValue "IMG_HEIGHT" "${IMG_HEIGHT}" "height" "${SENSOR_HEIGHT}")" ; then
    -		heading "Errors"
    -		echo -e "${X}"
    -		HAS_PIXEL_ERROR="true"
    -	fi
    -fi
    -
    -if [[ ${CROP_IMAGE} == "true" ]]; then
    -	if ! X="$(checkPixelValue "CROP_WIDTH" "${CROP_WIDTH}" "width" "${SENSOR_WIDTH}")" ; then
    -		heading "Errors"
    -		echo -e "${X}"
    -		HAS_PIXEL_ERROR="true"
    -	fi
    -	if ! X="$(checkPixelValue "CROP_HEIGHT" "${CROP_HEIGHT}" "height" "${SENSOR_HEIGHT}")" ; then
    -		heading "Errors"
    -		echo -e "${X}"
    -		HAS_PIXEL_ERROR="true"
    -	fi
    -	# "any" means it can be any number, positive or negative.
    -	if ! X="$(checkPixelValue "CROP_OFFSET_X" "${CROP_OFFSET_X}" "width" "${SENSOR_WIDTH}" "any")" ; then
    -		heading "Errors"
    -		echo -e "${X}"
    -		HAS_PIXEL_ERROR="true"
    -	fi
    -	if ! X="$(checkPixelValue "CROP_OFFSET_Y" "${CROP_OFFSET_Y}" "height" "${SENSOR_HEIGHT}" "any")" ; then
    -		heading "Errors"
    -		echo -e "${X}"
    -		HAS_PIXEL_ERROR="true"
    -	fi
    -
    -	# Do more intensive checks but only if there weren't IMG_RESIZE errors since we
    -	# we can't use IMG_WIDTH or IMG_HEIGHT.
    -	if [[ ${HAS_PIXEL_ERROR} == "false" ]]; then
    -		if [[ ${IMG_RESIZE} == "true" ]]; then
    -			MAX_X=${IMG_WIDTH}
    -			MAX_Y=${IMG_HEIGHT}
    -		else
    -			MAX_X=${SENSOR_WIDTH}
    -			MAX_Y=${SENSOR_HEIGHT}
    -		fi
    -		if ! X="$(checkCropValues "${CROP_WIDTH}" "${CROP_HEIGHT}" \
    -				"${CROP_OFFSET_X}" "${CROP_OFFSET_Y}" \
    -				"${MAX_X}" "${MAX_Y}")" ; then
    -			heading "Errors"
    -			echo -e "${X}"
    -		fi
    -	fi
    -fi
    -
    -if [[ ${RESIZE_UPLOADS} == "true" ]]; then
    -	if ! X="$(checkPixelValue "RESIZE_UPLOADS_WIDTH" "${RESIZE_UPLOADS_WIDTH}" "width" "${SENSOR_WIDTH}")" ; then
    -		heading "Errors"
    -		echo -e "${X}"
    -		echo "It is typically less than the sensor width of ${SENSOR_WIDTH}."
    -	fi
    -	if ! X="$(checkPixelValue "RESIZE_UPLOADS_HEIGHT" "${RESIZE_UPLOADS_HEIGHT}" "height" "${SENSOR_HEIGHT}")" ; then
    -		heading "Errors"
    -		echo -e "${X}"
    -		echo "It is typically less than the sensor height of ${SENSOR_HEIGHT}."
    -	fi
    -fi
    -
    -
    -# ======================================================================
    -# ================= Summary
    -RET=0
    -if [[ $((NUM_INFOS + NUM_WARNINGS + NUM_ERRORS)) -eq 0 ]]; then
    -	echo "No issues found."
    -else
    -	echo
    -	heading "Summary"
    -	[[ ${NUM_INFOS} -gt 0 ]] && echo "Informational messages: ${NUM_INFOS}"
    -	[[ ${NUM_WARNINGS} -gt 0 ]] && echo "Warnings: ${NUM_WARNINGS}" && RET=1
    -	[[ ${NUM_ERRORS} -gt 0 ]] && echo "Errors: ${NUM_ERRORS}" && RET=2
    -fi
    -
    -exit ${RET}
    diff --git a/scripts/convertJSON.php b/scripts/convertJSON.php
    new file mode 100755
    index 000000000..796ea7dbc
    --- /dev/null
    +++ b/scripts/convertJSON.php
    @@ -0,0 +1,395 @@
    +#!/usr/bin/php
    +<?php
    +
    +// Generic JSON modifying scripts needed because bash doesn't natively handle JSON.
    +// Output the names and values of each setting.
    +
    +// --settings-file SETTINGS_FILE
    +//		Optional name of the settings file.  If not specified use the standard file.
    +
    +// --options-file OPTIONS_FILE
    +//		Optional name of the options file.  If not specified use the standard file.
    +
    +$default_delimiter = "=";
    +// --delimiter D
    +//		Use "D" as the delimiter between the field name and its value.
    +
    +// --type T
    +//		Output setting names of the specified type T.
    +
    +// --settings-only
    +//		Only include settings that are in the settings file.
    +
    +// --capture-only
    +//		Limit output to only settings used by the capture_* programs.
    +//		Those settings have this field in the options file:		"capture" : true
    +//		Without this option ALL settings/values in the settings file are output.
    +
    +// --options-only
    +//		Include settings only in the options file, which are usually new settings.
    +//		Doesn't make sense to use this and --settings-only.
    +
    +// --carryforward
    +//		Limit output to only settings whose "carryforward" is true.
    +//		Output the setting name and setting type.
    +
    +// --convert
    +//		Convert the field names to all lower case,
    +//		boolean values to    true   or   false,
    +//		and remove any quotes around numbers and booleans.
    +//		Output a complete json file.
    +//		Cannot be used with --capture-only.  Ignores --delimiter.
    +
    +// --order
    +//		Order the output settings to be the same as what's in the options file.
    +//		Any setting NOT in the options file (e.g., "lastchanged") is added to the end.
    +
    +// --prefix P
    +//		Prepend "P" to the name of each setting.
    +//		Useful to ensure setting names don't conflict with bash names.
    +
    +// --shell
    +//		Output so it can be used in "eval":
    +//			- Quote values as needed, e.g., strings.
    +//			- Create variable with name of setting.
    +
    +include_once("functions.php");
    +
    +function quoteIt($string, $type)
    +{
    +	if ($type === "boolean" || $type === "float" ||
    +		$type === "integer" || $type === "percent") {
    +
    +		return($string);
    +	}
    +
    +	// Quote using single quotes so the shell doesn't expand anything.
    +	// There's no way to quote a single quote so change:
    +	//		how's
    +	//	to
    +	//		'how'"'"'s'
    +	return("'" . str_replace("'", "'\"'\"'", $string) . "'");
    +}
    +
    +$debug = false;
    +$settings_file = null;
    +$capture_only = false;
    +$carryforward = false;
    +$delimiter = $default_delimiter;
    +$convert = false;
    +$order = false;
    +$prefix = "";
    +$shell = false;
    +$type_to_output = "";
    +$options_file = null;
    +$include_not_in_options = false;
    +$options_only = false;
    +$options_array = null;
    +$only_in_settings_file = false;	// use only settings that are in settings file?
    +
    +$rest_index;
    +$longopts = array(
    +	"settings-file:",
    +	"options-file:",
    +	"delimiter:",
    +	"prefix:",
    +	"type:",
    +
    +	// no arguments:
    +	"settings-only",
    +	"capture-only",
    +	"carryforward",
    +	"include-not-in-options",
    +	"options-only",
    +	"convert",
    +	"order",
    +	"shell",
    +	"debug",
    +);
    +$options = getopt("", $longopts, $rest_index);
    +$ok=true;
    +foreach ($options as $opt => $val) {
    +	if ($debug || $opt === "debug") fwrite(STDERR, "===== Argument $opt $val\n");
    +
    +	if ($opt === "debug") {
    +		$debug = true;
    +
    +	} else if ($opt === "settings-file") {
    +		$settings_file = $val;
    +		if (! file_exists($settings_file)) {
    +			echo "ERROR: settings file '$settings_file' not found!\n";
    +			$ok = false;
    +		}
    +
    +	} else if ($opt === "options-file") {
    +		$options_file = $val;
    +		if (! file_exists($options_file)) {
    +			echo "ERROR: options file '$options_file' not found!\n";
    +			$ok = false;
    +		}
    +
    +	} else if ($opt === "capture-only") {
    +		$capture_only = true;
    +
    +	} else if ($opt === "carryforward") {
    +		$carryforward = true;
    +
    +	} else if ($opt === "type") {
    +		$type_to_output = $val;
    +
    +	} else if ($opt === "options-only") {
    +		$options_only = true;
    +
    +	} else if ($opt === "include-not-in-options") {
    +		$include_not_in_options = true;
    +
    +	} else if ($opt === "settings-only") {
    +		$only_in_settings_file = true;
    +
    +	} else if ($opt === "convert") {
    +		$convert = true;
    +
    +	} else if ($opt === "order") {
    +		$order = true;
    +
    +	} else if ($opt === "delimiter") {
    +		$delimiter = $val;
    +
    +	} else if ($opt === "shell") {
    +		$shell = true;
    +
    +	} else if ($opt === "prefix") {
    +		$prefix = $val;
    +
    +	} // else: getopt() doesn't return a bad argument
    +}
    +
    +if (! $ok || ($convert && $capture_only))
    +	exit(1);
    +
    +// =============================== main part of program =====================
    +//
    +if ($settings_file === null) {
    +	// use default
    +	$settings_file = getSettingsFile();
    +}
    +$errorMsg = "ERROR: Unable to process settings file '$settings_file'.";
    +$settings_array = get_decoded_json_file($settings_file, true, $errorMsg);
    +if ($settings_array === null) {
    +	exit(2);
    +}
    +
    +if ($convert) $shell = false;		// "convert" displays json; the shell needs shell format
    +
    +if ($options_file === null) {
    +	$options_file = getOptionsFile();	// use default file
    +}
    +
    +$errorMsg = "ERROR: Unable to process options file '$options_file'.";
    +$options_array = get_decoded_json_file($options_file, true, $errorMsg);
    +if ($options_array === null) {
    +	exit(3);
    +}
    +
    +if ($shell) $label_array = Array();
    +
    +$type_array = Array();
    +
    +foreach ($options_array as $option) {
    +	$type = getVariableOrDefault($option, 'type', "");
    +	if ($type_to_output !== "" && $type_to_output !== $type) {
    +		continue;
    +	}
    +
    +	$name = $option['name'];
    +	if ($carryforward && getVariableOrDefault($option, 'carryforward', "false") === "true") {
    +		echo "$prefix$name\t$type\n";
    +		continue;
    +	}
    +
    +	$type_array[$name] = $type;
    +	if ($shell) {
    +		$p = getVariableOrDefault($option, 'label_prefix', "");
    +		if ($p !== "") {
    +			$p .= " ";
    +		}
    +		$label_array[$name] = $p . getVariableOrDefault($option, 'label', "");
    +	}
    +}
    +
    +if ($carryforward) {
    +	// Did all the work above.
    +	exit(0);
    +}
    +
    +if ($type_to_output !== "") {
    +	foreach ($type_array as $name => $type) {
    +		if ($name !== $endSetting)
    +			echo "$prefix$name\n";
    +	}
    +	exit(0);
    +}
    +
    +if ($capture_only) {
    +	// Output only those settings and their values that are used by the capture program.
    +	// Order isn't important.
    +	foreach ($options_array as $option) {
    +		if (getVariableOrDefault($option, 'usage', "") != "capture")
    +			continue;
    +
    +		$name = $option['name'];
    +		$val = getVariableOrDefault($settings_array, $name, null);
    +		if ($val === null) {
    +			if ($options_only) {
    +				$val = getVariableOrDefault($option, "default", "");
    +			} else {
    +				continue;
    +			}
    +		}
    +
    +		if ($shell) {
    +			$val = quoteIt($val, $type_array[$name]);
    +			$label = getVariableOrDefault($label_array, $name, "");
    +			$val .= "; $prefix${name}_label=" . quoteIt($label, "text");
    +		}
    +		echo "$prefix$name$delimiter$val\n";
    +	}
    +	exit(0);
    +}
    +
    +
    +if ($convert || $order) {
    +	$mode = JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_NUMERIC_CHECK|JSON_PRESERVE_ZERO_FRACTION;
    +
    +	if ($convert) {
    +		// Convert settings names to lowercase.
    +		// Make sure booleans are output without quotes.
    +		// $mode handles no quotes around numbers.
    +		//
    +		$a = Array();
    +		foreach ($settings_array as $name => $value) {
    +			$name = strtolower($name);
    +			$a[$name] = $value;
    +		}
    +	} else {
    +		$a = $settings_array;
    +	}
    +
    +	if ($order) {
    +		// Put the output in the same order as the options file.
    +		$sort_array = Array();
    +		foreach ($options_array as $option) {
    +			$name = $option['name'];
    +			$val = getVariableOrDefault($a, $name, null);
    +
    +			// If needed, skip any option not in the settings file.
    +			if ($val === null) {
    +				if ($options_only) {
    +					$val = getVariableOrDefault($option, "default", "");
    +				} else {
    +					continue;
    +				}
    +			}
    +
    +			if ($type_array[$name] === "boolean")
    +				$val = toBool($val);
    +
    +			$sort_array[$name] = $val;
    +		}
    +
    +		echo json_encode($sort_array, $mode);
    +
    +	} else {
    +
    +		$new_settings_array = Array();
    +		foreach ($options_array as $option) {
    +			$name = $option['name'];
    +
    +			// If needed, skip any option not in the settings file.
    +			if ($only_in_settings_file &&
    +					getVariableOrDefault($a, $name, null) === null) {
    +				continue;
    +			}
    +
    +			$type = $type_array[$name];
    +			if ($type === "boolean") {
    +				$val = toBool(getVariableOrDefault($a, $name, "false"));
    +			} else {
    +				$val = getVariableOrDefault($a, $name, null);
    +				if ($val === null) {
    +					$val = getVariableOrDefault($option, 'default', "");
    +				}
    +			}
    +			// $mode handles no quotes around numbers.
    +
    +			if ($debug) { fwrite(STDERR, "$name: type=$type, val=$val\n"); }
    +
    +			$new_settings_array[$name] = $val;
    +		}
    +
    +		if ($include_not_in_options) {
    +			// Process any setting not in the options array,
    +			// which means it's not in $new_settings_array.
    +			// This will catch old settings.
    +			foreach ($a as $setting => $value) {
    +				if (getVariableOrDefault($new_settings_array, $setting, null) === null) {
    +					$new_settings_array[$setting] = $a[$setting];
    +				}
    +			}
    +		}
    +
    +		echo json_encode($new_settings_array, $mode);
    +	}
    +
    +} else if ($options_only) {
    +	foreach ($options_array as $option) {
    +		$name = $option['name'];
    +		if ($name === $endSetting) {
    +			continue;
    +		}
    +		if (getVariableOrDefault($option, "source", null) !== null) {
    +			continue;	// this setting isn't stored in the settings file.
    +		}
    +		$type = getVariableOrDefault($type_array, $name, "text");
    +		if (substr($type, 0, 6) === "header") {
    +			continue;	// not a setting
    +		}
    +		if (getVariableOrDefault($settings_array, $name, null) === null) {
    +			$default = getVariableOrDefault($option, "default", "");
    +			// Convert $type to a generic name
    +			if ($type === "color" || $type === "select_text" || $type === "widetext") {
    +				$type = "text";
    +			} else if  ($type === "float" || $type === "integer" ||
    +						$type === "select_integer" || $type === "percent") {
    +				$type = "number";
    +			}
    +			echo "$prefix$name$delimiter$default";
    +			if ($delimiter === $default_delimiter)
    +				echo "; $prefix${name}_type";
    +			echo "$delimiter$type\n";
    +		}
    +	}
    +
    +} else {
    +
    +	// Booleans are either 1 for true, or "" for false, so convert to "true" and "false".
    +	foreach ($settings_array as $name => $val) {
    +		$type = getVariableOrDefault($type_array, $name, "text");
    +		if ($type == "boolean") {
    +			// use "==" to catch numbers and booleans
    +			if ($val == 1)
    +				$val = "true";
    +			else if ($val == 0)
    +				$val = "false";
    +		}
    +		if ($shell) {
    +			$val = quoteIt($val, $type);
    +			$label = getVariableOrDefault($label_array, $name, "");
    +			$val .= "; $prefix${name}_label=" . quoteIt($label, "text");
    +		}
    +		echo "$prefix$name$delimiter$val\n";
    +	}
    +}
    +
    +exit(0);
    +?>
    diff --git a/scripts/copy_notification_image.sh b/scripts/copy_notification_image.sh
    index dc999407b..decc2871c 100755
    --- a/scripts/copy_notification_image.sh
    +++ b/scripts/copy_notification_image.sh
    @@ -1,17 +1,13 @@
     #!/bin/bash
     
     # Allow this script to be executed manually, which requires several variables to be set.
    -[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$(realpath "$(dirname "${BASH_ARGV0}")/..")"
    -ME="$(basename "${BASH_ARGV0}")"
    -
    -#shellcheck disable=SC2086 source-path=.
    -source "${ALLSKY_HOME}/variables.sh"		|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086 source-path=scripts
    -source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/config.sh"			|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/ftp-settings.sh"	|| exit ${ALLSKY_ERROR_STOP}
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
    +ME="$( basename "${BASH_ARGV0}" )"
    +
    +#shellcheck disable=SC1091 source-path=.
    +source "${ALLSKY_HOME}/variables.sh"		|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${EXIT_ERROR_STOP}"
     
     function usage_and_exit
     {
    @@ -25,8 +21,7 @@ function usage_and_exit
     		echo "  TextColor Font FontSize StrokeColor StrokeWidth BgColor BorderWidth BorderColor Extensions ImageSize 'Message'"
     		[[ ${RET} -ne 0 ]] && echo -e "${NC}"
     	) >&2
    -	# shellcheck disable=SC2086
    -	exit ${RET}
    +	exit "${RET}"
     }
     
     OK="true"
    @@ -62,11 +57,12 @@ done
     [[ -z ${EXPIRES_IN_SECONDS} ]] && usage_and_exit 2
     
     NOTIFICATION_TYPE="${1}"	# filename, minus the extension, since the extension may vary
    -[[ ${NOTIFICATION_TYPE} == "" ]] && usage_and_exit 1
    +[[ -z ${NOTIFICATION_TYPE} ]] && usage_and_exit 1
     
    +NUM_ARGS=12
     if [[ ${NOTIFICATION_TYPE} == "custom" ]]; then
    -	if [[ $# -ne 12 ]]; then
    -		echo -e "${RED}'custom' notification type requires 12 arguments" >&2
    +	if [[ $# -ne ${NUM_ARGS} ]]; then
    +		echo -e "${RED}'custom' notification type requires ${NUM_ARGS} arguments.${NC}" >&2
     		usage_and_exit 1
     	fi
     
    @@ -78,7 +74,7 @@ if [[ ${NOTIFICATION_TYPE} == "custom" ]]; then
     			"${7}" "${8}" "${9}" "${10:-${EXTENSION}}" "${11}" "${12}" ; then
     		exit 2			# it output error messages
     	fi
    -	NOTIFICATION_FILE="${ALLSKYCAPTURE_SAVE_DIR}/${NOTIFICATION_TYPE}.${EXTENSION}"
    +	NOTIFICATION_FILE="${CAPTURE_SAVE_DIR}/${NOTIFICATION_TYPE}.${EXTENSION}"
     else
     	NOTIFICATION_FILE="${ALLSKY_NOTIFICATION_IMAGES}/${NOTIFICATION_TYPE}.${EXTENSION}"
     	if [[ ! -e ${NOTIFICATION_FILE} ]]; then
    @@ -95,12 +91,12 @@ fi
     # We will APPEND to the file so we have a record of all notifications since Allsky started.
     
     if [[ ${NOTIFICATION_TYPE} != "custom" && -f ${ALLSKY_NOTIFICATION_LOG} && ${EXPIRES_IN_SECONDS} -ne 0 ]]; then
    -	NOW=$(date +'%Y-%m-%d %H:%M:%S')
    -	RESULTS="$(find "${ALLSKY_NOTIFICATION_LOG}" -newermt "${NOW}" -print)"
    +	NOW=$( date +'%Y-%m-%d %H:%M:%S' )
    +	RESULTS="$( find "${ALLSKY_NOTIFICATION_LOG}" -newermt "${NOW}" -print )"
     	if [[ -n ${RESULTS} ]]; then	# the file is in the future
     		if [[ ${ALLSKY_DEBUG_LEVEL} -ge 4 ]]; then
     			# File contains:	Notification_type,expires_in_seconds,expiration_time
    -			RECENT_NOTIFICATION=$(tail -1 "${ALLSKY_NOTIFICATION_LOG}")
    +			RECENT_NOTIFICATION=$( tail -1 "${ALLSKY_NOTIFICATION_LOG}" )
     			RECENT_TYPE=${RECENT_NOTIFICATION%%,*}
     			RECENT_TIME=${RECENT_NOTIFICATION##*,}
     			echo "${ME}: Ignoring new '${NOTIFICATION_TYPE}'; prior '${RECENT_TYPE}' posted ${RECENT_TIME}."
    @@ -116,14 +112,16 @@ else
     	# Don't overwrite notification images so create a temporary copy and use that.
     	CURRENT_IMAGE="${CAPTURE_SAVE_DIR}/notification-${FULL_FILENAME}"
     	if ! cp "${NOTIFICATION_FILE}" "${CURRENT_IMAGE}" ; then
    -		echo -e "${RED}*** ${ME}: ERROR: Cannot copy to CURRENT_IMAGE '${NOTIFICATION_FILE}' to '${CURRENT_IMAGE}'${NC}"
    +		echo -e "${RED}*** ${ME}: ERROR: Cannot copy '${NOTIFICATION_FILE}' to '${CURRENT_IMAGE}'${NC}"
     		exit 3
     	fi
     fi
     
     # Resize the image if required
    -if [[ ${IMG_RESIZE} == "true" ]]; then
    -	if ! convert "${CURRENT_IMAGE}" -resize "${IMG_WIDTH}x${IMG_HEIGHT}" "${CURRENT_IMAGE}" ; then
    +RESIZE_W="$( settings ".imageresizewidth" )"
    +if [[ ${RESIZE_W} -gt 0 ]]; then
    +	RESIZE_H="$( settings ".imageresizeheight" )"
    +	if ! convert "${CURRENT_IMAGE}" -resize "${RESIZE_W}x${RESIZE_H}" "${CURRENT_IMAGE}" ; then
     		echo -e "${RED}*** ${ME}: ERROR: IMG_RESIZE failed${NC}"
     		exit 3
     	fi
    @@ -134,20 +132,22 @@ fi
     # Don't save in main image directory because we don't want the notification image in timelapses.
     # If at nighttime, save them in (possibly) yesterday's directory.
     # If during day, save in today's directory.
    -if [[ $(settings ".takeDaytimeImages") == "1" && \
    -	  $(settings ".saveDaytimeImages") == "1" && \
    -	  ${IMG_CREATE_THUMBNAILS} == "true" ]]; then
    -	DATE_DIR="${ALLSKY_IMAGES}/$(date +'%Y%m%d')"
    +if [[ $( settings ".takedaytimeimages" ) == "true" && \
    +	  $( settings ".savedaytimeimages" ) == "true" && \
    +	  $( settings ".imagecreatethumbnails" ) == "true" ]]; then
    +	DATE_DIR="${ALLSKY_IMAGES}/$( date +'%Y%m%d' )"
     	# Use today's folder if it exists, otherwise yesterday's
    -	[[ ! -d ${DATE_DIR} ]] && DATE_DIR="${ALLSKY_IMAGES}/$(date -d '12 hours ago' +'%Y%m%d')"
    +	[[ ! -d ${DATE_DIR} ]] && DATE_DIR="${ALLSKY_IMAGES}/$( date -d '12 hours ago' +'%Y%m%d' )"
     	THUMBNAILS_DIR="${DATE_DIR}/thumbnails"
     	# The thumbnail isn't critical so continue if we can't create it.
     	if ! mkdir -p "${THUMBNAILS_DIR}" ; then
     			echo -e "${YELLOW}*** ${ME}: WARNING: could not create '${THUMBNAILS_DIR}'; continuing.${NC}"
     	else
    -		THUMB="${THUMBNAILS_DIR}/${FILENAME}-$(date +'%Y%m%d%H%M%S').${EXTENSION}"
    +		THUMB="${THUMBNAILS_DIR}/${FILENAME}-$( date +'%Y%m%d%H%M%S' ).${EXTENSION}"
     
    -		if ! convert "${CURRENT_IMAGE}" -resize "${THUMBNAIL_SIZE_X}x${THUMBNAIL_SIZE_Y}" "${THUMB}" ; then
    +		X="$( settings ".thumbnailsizex" )"
    +		Y="$( settings ".thumbnailsizey" )"
    +		if ! convert "${CURRENT_IMAGE}" -resize "${X}x${Y}" "${THUMB}" ; then
     			echo -e "${YELLOW}*** ${ME}: WARNING: THUMBNAIL resize failed; continuing.${NC}"
     		fi
     	fi
    @@ -158,20 +158,28 @@ fi
     # The "mv" may be a rename or an actual move.
     FINAL_IMAGE="${CAPTURE_SAVE_DIR}/${FULL_FILENAME}"
     if ! mv -f "${CURRENT_IMAGE}" "${FINAL_IMAGE}" ; then
    -	echo -e "${RED}*** ${ME}: ERROR: Cannot mv to FINAL_IMAGE: '${FINAL_IMAGE}' to '${TEMP_FILE}'${NC}"
    +	echo -e "${RED}*** ${ME}: ERROR: "
    +	if [[ -f ${CURRENT_IMAGE} ]]; then
    +		echo "Cannot mv '${CURRENT_IMAGE}' to '${FINAL_IMAGE}'"
    +	else
    +		echo "'${CURRENT_IMAGE}' does not exist!"
    +	fi
    +	echo -e "${NC}"
     	exit 4
     fi
     
     # Keep track of last notification type and time.
     # We don't use the type (at least not yet), but save it anyhow so we can manually look at
     # it for debugging purposes.
    -EXPIRE_TIME=$(date -d "${EXPIRES_IN_SECONDS} seconds" +'%Y-%m-%d %H:%M:%S')
    +EXPIRE_TIME=$( date -d "${EXPIRES_IN_SECONDS} seconds" +'%Y-%m-%d %H:%M:%S' )
     echo "${NOTIFICATION_TYPE},${EXPIRES_IN_SECONDS},${EXPIRE_TIME}" >> "${ALLSKY_NOTIFICATION_LOG}"
     touch --date="${EXPIRE_TIME}" "${ALLSKY_NOTIFICATION_LOG}"
     
     # If upload is true, optionally create a smaller version of the image, either way, upload it.
    -if [[ ${IMG_UPLOAD} == "true" ]]; then
    -	if [[ ${RESIZE_UPLOADS} == "true" ]]; then
    +if [[ $( settings ".imageuploadfrequency" ) -gt 0 ]]; then
    +	RESIZE_UPLOADS_WIDTH="$( settings ".imageresizeuploadswidth" )"
    +	if [[ ${RESIZE_UPLOADS_WIDTH} == "true" ]]; then
    +		RESIZE_UPLOADS_HEIGHT="$( settings ".imageresizeuploadsheight" )"
     		# Don't overwrite FINAL_IMAGE since the web server(s) may be looking at it.
     		TEMP_FILE="${CAPTURE_SAVE_DIR}/resize-${FULL_FILENAME}"
     
    @@ -196,17 +204,15 @@ if [[ ${IMG_UPLOAD} == "true" ]]; then
     	# We're actually uploading ${UPLOAD_FILE}, but show ${NOTIFICATION_FILE} in the message since it's more descriptive.
     	# If an existing notification is being uploaded, wait for it to finish then upload this one.
     	if [[ ${ALLSKY_DEBUG_LEVEL} -ge 4 ]]; then
    -		echo -e "${ME}: Uploading $(basename "${NOTIFICATION_FILE}")"
    +		echo -e "${ME}: Uploading $( basename "${NOTIFICATION_FILE}" )"
     	fi
    -	"${ALLSKY_SCRIPTS}/upload.sh" --wait --silent \
    -		"${UPLOAD_FILE}" "${IMAGE_DIR}" "${FULL_FILENAME}" "NotificationImage" "${WEB_IMAGE_DIR}"
    +	upload_all --local-web --remote-web --wait --silent "${UPLOAD_FILE}" "" "${FULL_FILENAME}" "NotificationImage"
     	RET=$?
     
     	# If we created a temporary copy, delete it.
     	[[ ${TEMP_FILE} != "" ]] && rm -f "${TEMP_FILE}"
     
    -	# shellcheck disable=SC2086
    -	exit ${RET}
    +	exit "${RET}"
     fi
     
     exit 0
    diff --git a/html/includes/createAllskyOptions.php b/scripts/createAllskyOptions.php
    similarity index 58%
    rename from html/includes/createAllskyOptions.php
    rename to scripts/createAllskyOptions.php
    index 2b6e5aee3..c945c2b74 100755
    --- a/html/includes/createAllskyOptions.php
    +++ b/scripts/createAllskyOptions.php
    @@ -54,31 +54,53 @@ function get_control($array, $setting, &$min, &$max, &$default) {
     // If a field is null that means it wasn't in the repo file,
     // so don't add it to the options string.
     // We need this because we look for all fields in a setting.
    -function add_non_null_field($a, $f, $setting) {	// array, field name, name_of_setting
    +function add_non_null_field($a, $f, $setting, $type=null) {	// array, field name, setting_name, type
    +	global $options_str;
    +	global $num_fields_this_setting;
    +
     	$value = getVariableOrDefault($a, $f, null);
     	if ($value === null) return;
     
    +	// Add command and newline to prior line starting with 2nd field.
    +	if ($num_fields_this_setting++ > 0) {
    +		$options_str .= ",\n";
    +	}
    +
     	if ($f === "options")
     		add_options_field($f, $value, $setting);
     	else
    -		add_field($f, $value, $setting);
    +		add_field($f, $value, $setting, $type);
     }
     
    -// Update $options_str with $v if it's not a string, and optionally if $return_string is set.
    -// Return true if we updated $options_str.
    -// This is needed because we never need to look in non-string values for "_min", "_max", etc.
    -function add_value($v, $return_string) {
    +// Format the given argument with or without quotes, depending on the argument type.
    +// If a type is specified, use it.
    +function quote_value($v, $type=null) {
     	global $q;
    -	global $options_str;
     
    -	if ($v === true || $v === false || $v === null || is_numeric($v)) {
    -		$options_str .= $v;
    -		return true;
    -	} else if ($return_string) {
    -		$options_str .= "$q$v$q";
    -		return true;
    +	if ($type === null)
    +		$type = gettype($v);
    +
    +	if ($type == "boolean") {
    +		if ($v === 0 || $v === "0" || $v === "false" || $v === false || ! $v)
    +			return("false");
    +		else
    +			return("true");
    +	} else if (is_numeric($v)) {
    +		return($v);
    +	} else {
    +		return("$q$v$q");
     	}
    -	return false;
    +}
    +
    +
    +// Return $v if it's not a string, and optionally if $return_string is set.
    +// This is needed because we never need to look in non-string values for "_min", "_max", etc.
    +function add_value($v, $return_string, $type=null) {
    +	$newV = quote_value($v, $type);
    +	if (! $return_string && substr($newV, 0, 1) == '"')
    +		return(null);		// Is a string so dont' return it.
    +	else
    +		return($newV);
     }
     
     // Add a field to the options string.
    @@ -86,7 +108,7 @@ function add_value($v, $return_string) {
     // For the value, we first need to check if it's a placeholder value,
     // and if so, replace the placeholder with the actual value from the camera capabilities file.
     // If it's not a placeholder value we just add it.
    -function add_field($f, $v, $setting) {	// field, value, name_of_setting
    +function add_field($f, $v, $setting, $type=null) {	// field, value, setting_name, type
     	global $q;
     	global $debug;
     	global $cc_controls;
    @@ -94,57 +116,58 @@ function add_field($f, $v, $setting) {	// field, value, name_of_setting
     	$options_str .= "$q$f$q : ";				// field name
     
     	// Do not add value if it's a string since we need to check if it needs to be replaced
    -	if (! add_value($v, false)) {
    -		if ($debug > 1) {
    -			// It's hard to read the output with really long strings.
    -			if (strlen($v) > 50) $vv = substr($v, 0, 50) . "...";
    -			else $vv = $v;
    -			echo "    '$f', v='$vv'";
    -		}
    +	$newV = add_value($v, false, $type);
    +	if ($newV !== null) {
    +		$options_str .= $newV;
    +		return;
    +	}
     
    -		// Check if the value is a generic placeholder, like "_min".
    -		// The "options" field is handled in add_options_field() since it's value is an array.
    -		// The "display" field was handled earlier.
    -		if (is_generic_value($v) || is_specific_value($v)) {
    -			$searchCC = true;
    -		} else {
    -			$searchCC = false;
    -		}
    +	if ($debug > 1 && $f !== "name") {
    +		// The "name" was already displayed.
    +		// It's hard to read the output with really long strings.
    +		if (strlen($v) > 50) $vv = substr($v, 0, 50) . "...";
    +		else $vv = $v;
    +		echo "    $f: $vv";
    +	}
     
    -		if ($searchCC) {
    -			// For generic values, if the setting is a day/night one, e.g., "dayexposure",
    -			// get just the "exposure" portion.
    -			// For specific values e.g., "daymean" : "day_default",
    -			// need to look up "daymean" in the CC file,
    -			// not "mean" like we do for generic values
    +	// Check if the value is a generic placeholder, like "_min".
    +	// The "options" field is handled in add_options_field() since it's value is an array.
    +	// The "display" field was handled earlier.
    +	if (is_generic_value($v) || is_specific_value($v)) {
    +		$searchCC = true;
    +	} else {
    +		$searchCC = false;
    +	}
     
    -			if (is_generic_value($v)) {
    -				$setting = get_generic_name($setting);
    +	if ($searchCC) {
    +		// For generic values, if the setting is a day/night one, e.g., "dayexposure",
    +		// get just the "exposure" portion.
    +		// For specific values e.g., "daymean" : "day_default",
    +		// need to look up "daymean" in the CC file,
    +		// not "mean" like we do for generic values
    +
    +		if (is_generic_value($v)) {
    +			$setting = get_generic_name($setting);
    +		}
    +		if (get_control($cc_controls, $setting, $min, $max, $default)) {
    +			$vReset = false;
    +			if ($f === "minimum") {
    +				$v = $min;
    +				$vReset = true;
    +			} else if ($f === "maximum") {
    +				$v = $max;
    +				$vReset = true;
    +			} else if ($f === "default") {
    +				$v = $default;
    +				$vReset = true;
     			}
    -			if (get_control($cc_controls, $setting, $min, $max, $default)) {
    -				$vReset = false;
    -				if ($f === "minimum") {
    -					$v = $min;
    -					$vReset = true;
    -				} else if ($f === "maximum") {
    -					$v = $max;
    -					$vReset = true;
    -				} else if ($f === "default") {
    -					$v = $default;
    -					$vReset = true;
    -				}
    -				if ($debug > 1) {
    -					if ($vReset) echo ", RESET v='$v'";
    -				}
    +			if ($debug > 1) {
    +				if ($vReset) echo ", RESET v='$v'";
     			}
     		}
    -		if ($debug > 1) echo "\n";
    -		$options_str .= "$q$v$q";
     	}
    -
    -	// The "display" field comes last in a setting, so don't append a comma to it.
    -	if ($f !== "display") $options_str .= ",";
    -	$options_str .= "\n";
    +	if ($debug > 1 && $f !== "name") echo "\n";
    +	$options_str .= quote_value($v, null);
     }
     
     // Add the options for the specified field to the options string.
    @@ -155,7 +178,11 @@ function handle_options($f) {
     	global $options_str;
     	global $cc_array;
     
    -	if ($f === "bin")
    +	if ($f === "cameratype")
    +		$cc_field = "cameraTypes";
    +	elseif ($f === "cameramodel")
    +		$cc_field = "cameraModels";
    +	elseif ($f === "bin")
     		$cc_field = "supportedBins";
     	elseif ($f === "type")
     		$cc_field = "supportedImageFormats";
    @@ -174,11 +201,11 @@ function handle_options($f) {
     	$num_options = count($cc_options);
     	foreach ($cc_options as $opt) {
     		if (is_array($opt)) {
    -			$options_str .= "\t" . '{';
    +			$options_str .= "\t{";
     			$num = count($opt);
     			foreach ($opt as $f => $v) {
    -				$options_str .= "$q$f$q : ";		// must split this line from next
    -				add_value($v, true);	// output if string or not
    +				// output if string or not
    +				$options_str .= "$q$f$q : " . add_value($v, true, null);
     				$num--;
     				if ($num > 0)
     					$options_str .= ", ";
    @@ -207,11 +234,11 @@ function add_options_field($field, $options, $setting) {
     	$num_options = count($options);
     	foreach ($options as $opt) {
     		if (is_array($opt)) {
    -			$options_str .= "\t" . '{';
    +			$options_str .= "\t{";
     			$num = count($opt);
     			foreach ($opt as $f => $v) {
    -				$options_str .= "$q$f$q : ";		// must split this line from next
    -				add_value($v, true);	// output if string or not
    +				// output if string or not
    +				$options_str .= "$q$f$q : " . add_value($v, true, null);
     				$num--;
     				if ($num > 0)
     					$options_str .= ", ";
    @@ -222,23 +249,23 @@ function add_options_field($field, $options, $setting) {
     			$options_str .= "\n";
     
     		} else {	// single value - check it for _values, etc.
    -			if ($opt === $setting . "_values") {
    +			if ($opt === "${setting}_values") {
     				handle_options($setting);
     			}
     		}
     	}
    -	$options_str .= "],\n";
    +	$options_str .= "]";
     }
     
     $rest_index;
     $longopts = array(
    -	"debug::",		// no arguments
    -	"debug2::",		// no arguments
    -	"help::",		// no arguments
    -	"cc_file:",
    -	"options_file:",
    -	"settings_file:",
    -	"force::",		// no arguments
    +	"debug",		// no arguments
    +	"debug2",		// no arguments
    +	"help",			// no arguments
    +	"cc-file:",
    +	"options-file:",
    +	"settings-file:",
    +	"force",		// no arguments
     );
     $options = getopt("", $longopts, $rest_index);
     
    @@ -250,7 +277,8 @@ function add_options_field($field, $options, $setting) {
     $force = false;		// force creation of settings file even if it already exists?
     
     foreach ($options as $opt => $val) {
    -	if ($debug > 1 || $opt === "debug") echo "   Argument $opt $val\n";
    +	// if ($debug > 1 || $opt === "debug" || $opt === "debug2") echo "   Argument $opt $val\n";
    +	if ($debug > 1) echo "   Argument $opt $val\n";
     
     	if ($opt === "debug")
     		$debug++;
    @@ -258,11 +286,11 @@ function add_options_field($field, $options, $setting) {
     		$debug = 2;
     	else if ($opt === "help")
     		$help = true;
    -	else if ($opt === "cc_file")
    +	else if ($opt === "cc-file")
     		$cc_file = $val;
    -	else if ($opt === "options_file")
    +	else if ($opt === "options-file")
     		$options_file = $val;
    -	else if ($opt === "settings_file")
    +	else if ($opt === "settings-file")
     		$settings_file = $val;
     	else if ($opt === "force")
     		$force = true;
    @@ -271,7 +299,7 @@ function add_options_field($field, $options, $setting) {
     }
     
     if ($help || $cc_file === "" || $options_file === "") {
    -	echo "\nUsage: " . basename($argv[0]) . " [--debug] [--debug2] [--help] [--settings_file file] --cc_file file --options_file file\n";
    +	echo "\nUsage: " . basename($argv[0]) . " [--debug] [--debug2] [--help] [--settings-file file] --cc-file file --options-file file\n";
     	exit(1);
     }
     
    @@ -293,8 +321,19 @@ function add_options_field($field, $options, $setting) {
     	exit(4);
     }
     $cc_controls = $cc_array["controls"];
    -$cameraType = $cc_array["cameraType"];
    -$cameraModel = $cc_array["cameraModel"];
    +$ok = true;
    +$cameraType = getVariableOrDefault($cc_array, "cameraType", "");
    +if ($cameraType === "") {
    +	echo "ERROR: cameraType empty in cc_array!\n";
    +	$ok = false;
    +}
    +$cameraModel = getVariableOrDefault($cc_array, "cameraModel", "");
    +if ($cameraModel === "") {
    +	echo "ERROR: cameraModel empty in cc_array!\n";
    +	$ok = false;
    +}
    +if (! $ok) exit(5);
    +
     if ($debug > 0) echo "cameraType=$cameraType, cameraModel=$cameraModel\n";
     
     // Read $repo_file
    @@ -304,21 +343,30 @@ function add_options_field($field, $options, $setting) {
     	exit(5);
     }
     
    -// All entries except the last "XX_END_XX" name have a "type". 
    -// All entries but type=="header" have a "name".
    +// All entries except the last "$endSetting" name have a "type". 
    +// All entries but type=="header*" have a "name".
     // Out of convention, the order of the fields is (a setting may not have all fields):
    -	// name				[string]
    -	// minimum			[number]
    -	// maximum			[number]
    -	// default			[string, but usually a number]
    -	// description		[string]
    -	// label			[string]
    -	// type				[string - header, number, text, checkbox, select, readonly]
    -	// options			[array with 1 or more entries] (only if "type" == "select")
    -	// checkchanges		[0/1]
    -	// optional			[0/1]
    -	// generic			[0/1]
    -	// display			[0/1]
    +	// name					[string]
    +	// display				[boolean]	# must be 2nd if present
    +	// settingsonly			[boolean]	# must be 3rd if present
    +	// minimum				[number]
    +	// maximum				[number]
    +	// default				[string, but usually a number]
    +	// description			[string]
    +	// label				[string]
    +	// label_prefix			[string]
    +	// type					[string]
    +	// usage				[string]
    +	// carryforward			[boolean]
    +	// options				[array with 1 or more entries] (only if "type" == "select_*")
    +	// checkchanges			[boolean]
    +	// source				[string]
    +	// booldependson		[string]	("name" of other setting)
    +	// booldependsoff		[string]	("name" of other setting)
    +	// popup-yesno			[string]
    +	// popup-yesno-value	[number or string]
    +	// optional				[boolean]
    +	// action				[string]
     
     
     // ==================   Create options file
    @@ -326,7 +374,7 @@ function add_options_field($field, $options, $setting) {
     // A "generic" value is one that's the same for day and night, e.g., the minimum value
     // for the "dayexposure" and "nightexposure".
     // These are often specified by the camera and have an "argumentName" in the CC
    -// file without the "day" or "night", e.g., "exposure.
    +// file without the "day" or "night", e.g., "exposure".
     
     // Field values that begin with "_", e.g., "_default" are generic placeholders; their
     // actual values need to be determined by looking in the CC file for the generic name.
    @@ -337,17 +385,25 @@ function add_options_field($field, $options, $setting) {
     // different values for day and night in the CC file, e.g., default value for day
     // and night exposure.
     
    +// How many fields for this setting have we output?
    +// Used to add commas to all but the last field.
    +$num_fields_this_setting = 0;
    +
     $options_str = "[\n";
     foreach ($repo_array as $repo) {
     	global $debug;
     	global $cc_controls;
    +	global $endSetting;
    +	global $num_fields_this_setting;
    +	$num_fields_this_setting = 0;
     
    -	$type = getVariableOrDefault($repo, "type", null);
     	$name = getVariableOrDefault($repo, "name", null);
    -	if ($type === null && $name === "XX_END_XX") {
    +	$type = getVariableOrDefault($repo, "type", null);
    +	if ($name === $endSetting) {
     		$options_str .= "{\n";
    -		$options_str .= "$q" . "name$q : $q$name$q,\n";
    -		$options_str .= "$q" . "display$q : 0\n";
    +		$options_str .= "${q}name${q} : ${q}$name${q},\n";
    +		$options_str .= "${q}type${q} : ${q}$type${q},\n";
    +		$options_str .= "${q}display${q} : false\n";
     		$options_str .= "}\n";
     		break;		// hit the end
     	}
    @@ -355,48 +411,65 @@ function add_options_field($field, $options, $setting) {
     	if ($debug > 1) echo "Processing setting [$name]: ";
     
     	// Before adding the setting, make sure the "display" field says we can.
    -	// The value will be 1 (can display) or 0 (don't display), or a placeholder.
    -	// It should normally not be missing, but check anyhow.
    +	// The value will be true (can display) or false (don't display), or a placeholder.
    +
     	$display = getVariableOrDefault($repo, "display", null);
    -	if ($display === null || $display === 0) {
    -		if ($debug > 1) echo "    display field is null or 0\n";
    +	if ($display === null) {
    +		$display = "true";		// default
    +	} else if ($display === "false") {
    +		if ($debug > 1) echo "    'display' field is false; skipping\n";
     		continue;
     	}
    +
     	if (is_generic_value($display)) {
     		// Is a placeholder - need to check if the setting is in the CC file.
     		// If not, don't output this setting.
     		if (! get_control($cc_controls, get_generic_name($name), $min, $max, $default)) {
    -			if ($debug > 1) echo "     <<<<< NOT SUPPORTED >>>>>\n";
    +			if ($debug > 1) {
    +				echo "\n$name: <<<<< NOT SUPPORTED >>>>>\n";
    +			}
     			// Not an error - just means this isn't supported.
     			continue;
     		}
    -		$repo["display"] = 1;	// a control exists for it, so display the setting.
     	}
     	if ($debug > 1) echo "\n";
     
     	// Have to handle camera type and model differently because the defaults
     	// might not be what we want.
    -	if ($name === "cameraType")
    +	if ($name === "cameratype")
     			$repo["default"] = $cameraType;
    -	elseif ($name === "cameraModel")
    +	elseif ($name === "cameramodel")
     			$repo["default"] = $cameraModel;
     	elseif ($name === "camera")
     			$repo["default"] = "$cameraType $cameraModel";
     
     	$options_str .= "{\n";
     		add_non_null_field($repo, "name", $name);
    -		add_non_null_field($repo, "minimum", $name);
    -		add_non_null_field($repo, "maximum", $name);
    -		add_non_null_field($repo, "default", $name);
    -		add_non_null_field($repo, "description", $name);
    -		add_non_null_field($repo, "label", $name);
    -		add_non_null_field($repo, "type", $name);
    -		add_non_null_field($repo, "options", $name);
    -		add_non_null_field($repo, "checkchanges", $name);
    -		add_non_null_field($repo, "optional", $name);
    -		add_non_null_field($repo, "generic", $name);
    -		add_non_null_field($repo, "display", $name);
    -	$options_str .= "},\n";
    +
    +		// Only get here if display is true so no need to add "display" field.
    +
    +		if (getVariableOrDefault($repo, "settingsonly", "false") === "true") {
    +			add_non_null_field($repo, "settingsonly", $name, "boolean");
    +			add_non_null_field($repo, "label", $name);
    +			add_non_null_field($repo, "label_prefix", $name);
    +			add_non_null_field($repo, "type", $name);
    +		} else {
    +			add_non_null_field($repo, "minimum", $name);
    +			add_non_null_field($repo, "maximum", $name);
    +			add_non_null_field($repo, "default", $name, $type);
    +			add_non_null_field($repo, "description", $name);
    +			add_non_null_field($repo, "label", $name);
    +			add_non_null_field($repo, "label_prefix", $name);
    +			add_non_null_field($repo, "type", $name);
    +			add_non_null_field($repo, "usage", $name);
    +			add_non_null_field($repo, "carryforward", $name, "boolean");
    +			add_non_null_field($repo, "options", $name);
    +			add_non_null_field($repo, "checkchanges", $name, "boolean");
    +			add_non_null_field($repo, "optional", $name, "boolean");
    +			add_non_null_field($repo, "source", $name);
    +			add_non_null_field($repo, "action", $name);
    +		}
    +	$options_str .= "\n},\n";
     }
     $options_str .= "]\n\n";
     
    @@ -418,13 +491,24 @@ function add_options_field($field, $options, $setting) {
     if ($settings_file !== "") {
     	// Determine the name of the camera type/model-specific file.
     	$pieces = explode(".", basename($settings_file));		// e.g., "settings.json"
    +	if (count($pieces) !== 2) {
    +		echo "ERROR: invalid name: '$settings_file'\n";
    +		exit(7);
    +	}
     	$FileName = $pieces[0];		// e.g., "settings"
     	$FileExt = $pieces[1];		// e.g., "json"
     	// e.g., "settings_ZWO_ASI123.json"
    +
    +	// The camera model may have spaces which is a hassle in file names,
    +	// so convert to underscores.
    +	$cameraModel = str_replace(" ", "_", $cameraModel);
    +
     	$cameraSpecificSettingsName = $FileName . "_$cameraType" . "_$cameraModel.$FileExt";
     	$fullSpecificFileName = dirname($settings_file) . "/$cameraSpecificSettingsName";
    +	$specificFileExists =  file_exists($fullSpecificFileName);
     	if ($debug > 0) {
    -		$e =  file_exists($fullSpecificFileName) ? "yes" : "no";
    +		$e =  $specificFileExists ? "yes" : "no";
    +		if ($debug > 1) echo "\n";
     		echo "Camera-specific settings file exists ($e): $fullSpecificFileName.\n";
     	}
     
    @@ -433,55 +517,55 @@ function add_options_field($field, $options, $setting) {
     	$settings_array = null;
     	if (file_exists($settings_file)) {
     		$errorMsg = "ERROR: Unable to process prior settings file '$settings_file'.";
    -		$settings_array = get_decoded_json_file($settings_file, true, $errorMsg);
    +
    +		if ($specificFileExists)
    +			$settings_array = get_decoded_json_file($settings_file, true, $errorMsg);
     
     		if ($debug > 0) echo "Removing $settings_file.\n";
     		if (! unlink($settings_file)) {
     			echo "ERROR: Unable to delete $settings_file.\n";
    -			exit(7);
    +			exit(8);
     		}
     	}
     
    -
     	// If there isn't a camera-specific file, create one.
    -	if ($force || ! file_exists($fullSpecificFileName)) {
    +	if ($force || ! $specificFileExists) {
     		// For each item in the options file, write the name and a value.
    -		$contents = "{\n";
    +		$new_settings = Array();
     		$options_array = json_decode($options_str, true);
     		foreach ($options_array as $option) {
     			$type = getVariableOrDefault($option, 'type', "");
    -			if ($type == "header") continue;	// don't put in settings file
    -			$display = getVariableOrDefault($option, 'display', 0);
    -			if ($display === 0) continue;
    +
    +			if (substr($type, 0, 6) == "header" ||
    +					getVariableOrDefault($option, 'source', null) !== null ||
    +					getVariableOrDefault($option, 'display', "true") == "false") {
    +				continue;	// don't put in settings file
    +			}
     
     			$name = $option['name'];
     
    -			// If it's a generic setting, use it's prior value if it exists.
    -			if (getVariableOrDefault($option, 'generic', 0) !== 0 && $settings_array !== null) {
    +			// If there's a previous value, use it, else use the default.
    +			if ($settings_array !== null) {
     				$val = getVariableOrDefault($settings_array, $name, null);
     			} else {
    -				$val = null;
    -			}
    -
    -			if ($val === null) {
     				$val = getVariableOrDefault($option, 'default', "");
    -				if ($debug > 1) echo ">> default $name = [$val]\n";
    -			} else {
    -				if ($debug > 1) echo ">> generic $name = [$val]\n";
     			}
    -			// Don't worry about whether or not the default is a string, number, etc.
    -			$contents .= "\t\"$name\" : \"$val\",\n";
    +			if ($type == "boolean") {
    +				if ($val == "true") $val = true;
    +				else $val = false;
    +			}
    +			$new_settings[$name] = $val;
     		}
    -		// This comes last so we don't worry about whether or not the items above
    -		// need a trailing comma.
    -		$contents .= "\t\"XX_END_XX\" : 1\n";
    -		$contents .= "}\n";
    +		$new_settings[$endSetting] = true;
    +
    +		$mode = JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_NUMERIC_CHECK|JSON_PRESERVE_ZERO_FRACTION;
    +		$contents = json_encode($new_settings, $mode);
     
     		if ($debug > 0) echo "Creating camera-specific settings file: $fullSpecificFileName.\n";
     		$results = updateFile($fullSpecificFileName, $contents, $cameraSpecificSettingsName, true);
     		if ($results != "") {
     			echo "ERROR: Unable to create $fullSpecificFileName.\n";
    -			exit(8);
    +			exit(9);
     		}
     
     	} else if ($debug > 0) {
    @@ -494,7 +578,7 @@ function add_options_field($field, $options, $setting) {
     	if ($debug > 0) echo "Linking $fullSpecificFileName to $settings_file.\n";
     	if (! link($fullSpecificFileName, $settings_file)) {
     		echo "ERROR: Unable to link $fullSpecificFileName to $settings_file.\n";
    -		exit(9);
    +		exit(10);
     	}
     }
     
    diff --git a/scripts/darkCapture.sh b/scripts/darkCapture.sh
    index f3a7fa906..55f80c5f2 100755
    --- a/scripts/darkCapture.sh
    +++ b/scripts/darkCapture.sh
    @@ -2,7 +2,7 @@
     
     # This file is "source"d into another.
     # "${CURRENT_IMAGE}" is the full pathname of the current image we're working on and is passed to us.
    -ME2="$(basename "${BASH_SOURCE[0]}")"
    +ME2="$( basename "${BASH_SOURCE[0]}" )"
     
     # Make sure the input file exists; if not, something major is wrong so exit.
     if [[ -z ${CURRENT_IMAGE} ]]; then
    @@ -14,7 +14,7 @@ if [[ ! -f ${CURRENT_IMAGE} ]]; then
     	exit 2
     fi
     
    -# The extension on $CURRENT_IMAGE may not be $EXTENSION.
    +# The extension on ${CURRENT_IMAGE} may not be ${EXTENSION}.
     DARK_EXTENSION="${CURRENT_IMAGE##*.}"
     
     DARKS_DIR="${ALLSKY_DARKS}"
    @@ -23,7 +23,7 @@ if [[ -z ${AS_TEMPERATURE_C} ]]; then
     	# The camera doesn't support temperature so we'll keep overwriting the file until
     	# AS_TEMPERATURE_C is set.
     	# This allows users to continually look for a new dark file and rename it manually.
    -	MOVE_TO_FILE="${DARKS_DIR}/$(basename "${CURRENT_IMAGE}")"
    +	MOVE_TO_FILE="${DARKS_DIR}/$( basename "${CURRENT_IMAGE}" )"
     else
     	MOVE_TO_FILE="${DARKS_DIR}/${AS_TEMPERATURE_C}.${DARK_EXTENSION}"
     fi
    @@ -35,8 +35,8 @@ mv "${CURRENT_IMAGE}" "${MOVE_TO_FILE}" || exit 3
     # Some people may want to see the dark frame even if notification images
     # are being used, but no one's askef for that feature so don't worry about it.
     
    -if [[ $(settings ".notificationimages") -eq 0 ]]; then
    +if [[ $( settings ".notificationimages" ) == "false" ]]; then
     	# We're copying back the file we just moved, but the assumption is few people
     	# will want to see the dark frames so the performance hit is 
    -	cp "${MOVE_TO_FILE}" "${ALLSKY_TMP}/${FILENAME}.${EXTENSION}"
    +	cp "${MOVE_TO_FILE}" "${ALLSKY_TMP}/${FILENAME}.${EXTENSION}" || exit 4
     fi
    diff --git a/scripts/darkSubtract.sh b/scripts/darkSubtract.sh
    index e2550efc1..694589d98 100755
    --- a/scripts/darkSubtract.sh
    +++ b/scripts/darkSubtract.sh
    @@ -1,97 +1,97 @@
     #!/bin/bash
     
    -# This file is "source"d into another.
    +# This file is "source"d into another if we're subtracting darks.
     # "${CURRENT_IMAGE}" is the name of the current image we're working on.
     
    -ME2="$(basename "${BASH_SOURCE[0]}")"
    +ME2="$( basename "${BASH_SOURCE[0]}" )"
     
    -# Subtract dark frame if there is one defined in config.sh
    +# Subtract dark frame if there is one.
     # This has to come after executing darkCapture.sh which sets ${AS_TEMPERATURE_C}.
     
    -if [[ $(settings ".useDarkFrames") -eq 1 ]]; then
    -	# Make sure the input file exists; if not, something major is wrong so exit.
    -	if [[ -z ${CURRENT_IMAGE} ]]; then
    -		echo "*** ${ME2}: ERROR: 'CURRENT_IMAGE' not set; aborting."
    -		exit 1
    -	fi
    -	if [[ ! -f ${CURRENT_IMAGE} ]]; then
    -		echo "*** ${ME2}: ERROR: '${CURRENT_IMAGE}' does not exist; aborting."
    -		exit 2
    -	fi
    +# Make sure the input file exists; if not, something major is wrong so exit.
    +if [[ -z ${CURRENT_IMAGE} ]]; then
    +	echo "*** ${ME2}: ERROR: 'CURRENT_IMAGE' not set; aborting." >&2
    +	exit 1
    +fi
    +if [[ ! -f ${CURRENT_IMAGE} ]]; then
    +	echo "*** ${ME2}: ERROR: '${CURRENT_IMAGE}' does not exist; aborting." >&2
    +	exit 2
    +fi
     
    -	# Make sure we know the current temperature.
    -	# If it doesn't exist, warn the user but continue.
    -	if [[ -z ${AS_TEMPERATURE_C} ]]; then
    -		echo "*** ${ME2}: WARNING: 'AS_TEMPERATURE_C' not set; continuing without dark subtraction."
    -		return
    -	fi
    -	# Some cameras don't have a sensor temp, so don't attempt dark subtraction for them.
    -	[[ ${AS_TEMPERATURE_C} == "n/a" ]] && return
    +# Make sure we know the current temperature.
    +# If it doesn't exist, warn the user but continue.
    +if [[ -z ${AS_TEMPERATURE_C} ]]; then
    +	echo "*** ${ME2}: WARNING: 'AS_TEMPERATURE_C' not set; continuing without dark subtraction." >&2
    +	return
    +fi
    +# Some cameras don't have a sensor temp, so don't attempt dark subtraction for them.
    +[[ ${AS_TEMPERATURE_C} == "n/a" ]] && return
     
    -	# First check if we have an exact match.
    -	DARKS_DIR="${ALLSKY_DARKS}"
    -	DARK="${DARKS_DIR}/${AS_TEMPERATURE_C}.${EXTENSION}"
    -	if [[ -s ${DARK} ]]; then
    -		CLOSEST_TEMPERATURE="${AS_TEMPERATURE_C}"
    -	else
    -		# Find the closest dark frame temperature wise
    -		typeset -i CLOSEST_TEMPERATURE	# don't set yet
    -		typeset -i DIFF=100		# any sufficiently high number
    -		typeset -i AS_TEMPERATURE_C=${AS_TEMPERATURE_C##*(0)}
    -		typeset -i OVERDIFF		# DIFF when dark file temp > ${AS_TEMPERATURE_C}
    -		typeset -i DARK_TEMPERATURE
    +# First check if we have an exact match.
    +DARKS_DIR="${ALLSKY_DARKS}"
    +DARK="${DARKS_DIR}/${AS_TEMPERATURE_C}.${EXTENSION}"
    +if [[ -s ${DARK} ]]; then
    +	CLOSEST_TEMPERATURE="${AS_TEMPERATURE_C}"
    +else
    +	# Find the closest dark frame temperature wise
    +	typeset -i CLOSEST_TEMPERATURE	# don't set yet
    +	typeset -i DIFF=100		# any sufficiently high number
    +	typeset -i AS_TEMPERATURE_C=${AS_TEMPERATURE_C##*(0)}
    +	typeset -i OVERDIFF		# DIFF when dark file temp > ${AS_TEMPERATURE_C}
    +	typeset -i DARK_TEMPERATURE
     
    -		# Sort the files by temperature so once we find a file at a higher temperature
    -		# than ${AS_TEMPERATURE_C}, stop, then compare it to the previous file to
    -		# determine which is closer to ${AS_TEMPERATURE_C}.
    -		# Need "--general-numeric-sort" in case any files have a leading "-".
    -		for file in $(find "${DARKS_DIR}" -maxdepth 1 -iname "*.${EXTENSION}" | sed 's;.*/;;' | sort --general-numeric-sort)
    -		do
    -			[[ ${ALLSKY_DEBUG_LEVEL} -ge 5 ]] && echo "Looking at ${file}"
    -			# Example file name for 21 degree dark: "21.jpg".
    -			if [[ -s ${DARKS_DIR}/${file} ]]; then
    -				file="$(basename "./${file}")"	# need "./" in case file has "-"
    -				# Get name of file (which is the temp) without extension
    -				DARK_TEMPERATURE=${file%.*}
    -				if [[ ${DARK_TEMPERATURE} -gt ${AS_TEMPERATURE_C} ]]; then
    -					OVERDIFF=$((DARK_TEMPERATURE - AS_TEMPERATURE_C))
    -					if [[ ${OVERDIFF} -lt ${DIFF} ]]; then
    -						CLOSEST_TEMPERATURE=${DARK_TEMPERATURE}
    -					fi
    -					break
    +	# Sort the files by temperature so once we find a file at a higher temperature
    +	# than ${AS_TEMPERATURE_C}, stop, then compare it to the previous file to
    +	# determine which is closer to ${AS_TEMPERATURE_C}.
    +	# Need "--general-numeric-sort" in case any files have a leading "-".
    +	for file in $( find "${DARKS_DIR}" -maxdepth 1 -iname "*.${EXTENSION}" |
    +		sed 's;.*/;;' | sort --general-numeric-sort )
    +	do
    +		# Example file name for 21 degree dark: "21.jpg".
    +		if [[ -s ${DARKS_DIR}/${file} ]]; then
    +			file="$( basename "./${file}" )"	# need "./" in case file has "-"
    +			# Get name of file (which is the temp) without extension
    +			DARK_TEMPERATURE=${file%.*}
    +			if [[ ${DARK_TEMPERATURE} -gt ${AS_TEMPERATURE_C} ]]; then
    +				OVERDIFF=$((DARK_TEMPERATURE - AS_TEMPERATURE_C))
    +				if [[ ${OVERDIFF} -lt ${DIFF} ]]; then
    +					CLOSEST_TEMPERATURE=${DARK_TEMPERATURE}
     				fi
    -				CLOSEST_TEMPERATURE=${DARK_TEMPERATURE}
    -				DIFF=$((AS_TEMPERATURE_C - CLOSEST_TEMPERATURE))
    +				break
    +			fi
    +			CLOSEST_TEMPERATURE=${DARK_TEMPERATURE}
    +			DIFF=$((AS_TEMPERATURE_C - CLOSEST_TEMPERATURE))
    +		else
    +			echo -n "${ME2}: INFORMATION: dark file '${DARKS_DIR}/${file}' " >&2
    +			if [[ ! -f ${DARKS_DIR}/${file} ]]; then
    +				echo "${file} does not exist  Huh?." >&2
     			else
    -				
    -				echo -n "${ME2}: INFORMATION: dark file '${DARKS_DIR}/${file}' "
    -				if [[ ! -f ${DARKS_DIR}/${file} ]]; then
    -					echo "${file} does not exist  Huh?."
    -				else
    -					echo "${file} zero-length; deleting."
    -					ls -l "${DARKS_DIR}/${file}"
    -					rm -f "${DARKS_DIR}/${file}"
    -				fi
    +				echo "${file} zero-length; deleting." >&2
    +				ls -l "${DARKS_DIR}/${file}" >&2
    +				rm -f "${DARKS_DIR}/${file}"
     			fi
    -		done
    -
    -		if [[ ${CLOSEST_TEMPERATURE} == "" ]]; then
    -			echo "*** ${ME2}: ERROR: No dark frame found for ${CURRENT_IMAGE} at temperature ${AS_TEMPERATURE_C}."
    -			echo "Either take dark frames or turn 'Use Dark Frames' off in the WebUI"
    -			echo "Continuing without dark subtraction."
    -			return
     		fi
    +	done
     
    -		DARK="${DARKS_DIR}/${CLOSEST_TEMPERATURE}.${EXTENSION}"
    +	if [[ ${CLOSEST_TEMPERATURE} == "" ]]; then
    +		{
    +		echo "*** ${ME2}: ERROR: No dark frame found for ${CURRENT_IMAGE} at temperature ${AS_TEMPERATURE_C}."
    +		echo "Either take dark frames or turn 'Use Dark Frames' off in the WebUI"
    +		echo "Continuing without dark subtraction."
    +		} >&2
    +		return
     	fi
     
    -	if [[ ${ALLSKY_DEBUG_LEVEL} -ge 4 ]]; then
    -		echo "${ME2}: Subtracting dark frame '${CLOSEST_TEMPERATURE}.${EXTENSION}' from image with temperature=${AS_TEMPERATURE_C}"
    -	fi
    -	# Update the current image - don't rename it.
    -	if ! convert "${CURRENT_IMAGE}" "${DARK}" -compose minus_src -composite "${CURRENT_IMAGE}" ; then
    -		# Exit since we don't know the state of ${CURRENT_IMAGE}.
    -		echo "*** ${ME2}: ERROR: 'convert' of '${DARK}' failed"
    -		exit 4
    -	fi
    +	DARK="${DARKS_DIR}/${CLOSEST_TEMPERATURE}.${EXTENSION}"
    +fi
    +
    +if [[ ${ALLSKY_DEBUG_LEVEL} -ge 4 ]]; then
    +	echo -n "${ME2}: Subtracting dark frame '${CLOSEST_TEMPERATURE}.${EXTENSION}'"
    +	echo    " from image with temperature=${AS_TEMPERATURE_C}"
    +fi
    +# Update the current image - don't rename it.
    +if ! convert "${CURRENT_IMAGE}" "${DARK}" -compose minus_src -composite "${CURRENT_IMAGE}" ; then
    +	# Exit since we don't know the state of ${CURRENT_IMAGE}.
    +	echo "*** ${ME2}: ERROR: 'convert' of '${DARK}' failed" >&2
    +	exit 4
     fi
    diff --git a/scripts/endOfDay.sh b/scripts/endOfDay.sh
    index c540329a0..61730eb13 100755
    --- a/scripts/endOfDay.sh
    +++ b/scripts/endOfDay.sh
    @@ -1,17 +1,13 @@
     #!/bin/bash
     
     # Allow this script to be executed manually, which requires several variables to be set.
    -[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$(realpath "$(dirname "${BASH_ARGV0}")/..")"
    -ME="$(basename "${BASH_ARGV0}")"
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
    +ME="$( basename "${BASH_ARGV0}" )"
     
    -#shellcheck disable=SC2086 source-path=.
    -source "${ALLSKY_HOME}/variables.sh"			|| exit  ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086 source-path=scripts
    -source "${ALLSKY_SCRIPTS}/functions.sh"			|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/config.sh"				|| exit  ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/ftp-settings.sh"		|| exit  ${ALLSKY_ERROR_STOP}
    +#shellcheck source-path=.
    +source "${ALLSKY_HOME}/variables.sh"			|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/functions.sh"			|| exit "${EXIT_ERROR_STOP}"
     
     if [[ $# -eq 1 ]]; then
     	if [[ ${1} == "--help" ]]; then
    @@ -21,13 +17,13 @@ if [[ $# -eq 1 ]]; then
     		DATE="${1}"
     	fi
     else
    -	DATE=$(date +'%Y%m%d')
    +	DATE=$( date +'%Y%m%d' )
     fi
     
     # If we weren't saving daytime images the directory won't exist.
    -SAVING="$( settings .saveDaytimeImages )"
    +SAVING="$( settings ".savedaytimeimages" )"
     DATE_DIR="${ALLSKY_IMAGES}/${DATE}"
    -if [[ ! -d ${DATE_DIR} && ${SAVING} -eq 1 ]]; then
    +if [[ ! -d ${DATE_DIR} && ${SAVING} == "true" ]]; then
     	echo -e "${ME}: ${RED}ERROR: '${DATE_DIR}' not found!${NC}"
     	exit 2
     fi
    diff --git a/scripts/endOfNight.sh b/scripts/endOfNight.sh
    index ed09a3f8d..3ec26d4ff 100755
    --- a/scripts/endOfNight.sh
    +++ b/scripts/endOfNight.sh
    @@ -3,19 +3,15 @@
     # This script has two main purposes:
     #	1. Optionally create a keogram, startrails, and timelapse video for the specified day.
     #	2. Perform daily housekeeping not related to the specified day, like removing old files.
    -
    +set -a
     # Allow this script to be executed manually, which requires several variables to be set.
    -[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$(realpath "$(dirname "${BASH_ARGV0}")/..")"
    -ME="$(basename "${BASH_ARGV0}")"
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
    +ME="$( basename "${BASH_ARGV0}" )"
     
     #shellcheck source-path=.
    -source "${ALLSKY_HOME}/variables.sh"		|| exit "${ALLSKY_ERROR_STOP}"
    +source "${ALLSKY_HOME}/variables.sh"		|| exit "${EXIT_ERROR_STOP}"
     #shellcheck source-path=scripts
    -source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${ALLSKY_ERROR_STOP}"
    -#shellcheck disable=SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/config.sh"			|| exit "${ALLSKY_ERROR_STOP}"
    -#shellcheck disable=SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/ftp-settings.sh"	|| exit "${ALLSKY_ERROR_STOP}"
    +source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${EXIT_ERROR_STOP}"
     
     if [[ $# -eq 1 ]]; then
     	if [[ ${1} = "--help" ]]; then
    @@ -26,7 +22,7 @@ if [[ $# -eq 1 ]]; then
     		DATE="${1}"
     	fi
     else
    -	DATE=$(date -d 'yesterday' +'%Y%m%d')
    +	DATE=$( date -d 'yesterday' +'%Y%m%d' )
     fi
     
     DATE_DIR="${ALLSKY_IMAGES}/${DATE}"
    @@ -36,7 +32,7 @@ if [[ ! -d ${DATE_DIR} ]]; then
     fi
     
     # Decrease priority when running in background.
    -if [[ ${ON_TTY} -eq 1 ]]; then
    +if [[ ${ON_TTY} == "true" ]]; then
     	NICE=""
     	NICE_ARG=""
     else
    @@ -45,7 +41,7 @@ else
     fi
     
     # Post end of night data. This includes next twilight time
    -WEBSITES="$(whatWebsites)"
    +WEBSITES="$( whatWebsites )"
     
     if [[ ${WEBSITES} != "none" ]]; then
     	echo -e "${ME}: ===== Posting twilight data"
    @@ -53,26 +49,26 @@ if [[ ${WEBSITES} != "none" ]]; then
     fi
     
     # Generate keogram from collected images
    -if [[ ${KEOGRAM} == "true" ]]; then
    +if [[ $( settings ".keogramgenerate" ) == "true" ]]; then
     	echo -e "${ME}: ===== Generating Keogram for ${DATE}"
     	#shellcheck disable=SC2086
     	"${ALLSKY_SCRIPTS}/generateForDay.sh" ${NICE_ARG} --silent --keogram "${DATE}"
     	RET=$?
     	echo -e "${ME}: ===== Keogram complete"
    -	if [[ ${UPLOAD_KEOGRAM} == "true" && ${RET} = 0 ]] ; then
    +	if [[ $( settings ".keogramupload" ) == "true" && ${RET} = 0 ]] ; then
     		"${ALLSKY_SCRIPTS}/generateForDay.sh" --upload --keogram "${DATE}"
     	fi
     fi
     
     # Generate startrails from collected images.
    -# Threshold set to 0.1 by default in config.sh to avoid stacking over-exposed images.
    -if [[ ${STARTRAILS} == "true" ]]; then
    +# Threshold set to 0.1 by default to avoid stacking over-exposed images.
    +if [[ $( settings ".startrailsgenerate" ) == "true" ]]; then
     	echo -e "${ME}: ===== Generating Startrails for ${DATE}"
     	#shellcheck disable=SC2086
     	"${ALLSKY_SCRIPTS}/generateForDay.sh" ${NICE_ARG} --silent --startrails "${DATE}"
     	RET=$?
     	echo -e "${ME}: ===== Startrails complete"
    -	if [[ ${UPLOAD_STARTRAILS} == "true" && ${RET} = 0 ]] ; then
    +	if [[ $( settings ".startrailsupload" ) == "true" && ${RET} = 0 ]] ; then
     		"${ALLSKY_SCRIPTS}/generateForDay.sh" --upload --startrails "${DATE}"
     	fi
     fi
    @@ -80,52 +76,44 @@ fi
     # Generate timelapse from collected images.
     # Use generateForDay.sh instead of putting all the commands here so users can easily
     # test the timelapse creation, which sometimes has issues.
    -if [[ ${TIMELAPSE} == "true" ]]; then
    +if [[ $( settings ".timelapsegenerate" ) == "true" ]]; then
     	echo -e "${ME}: ===== Generating Timelapse for ${DATE}"
     	#shellcheck disable=SC2086
     	"${ALLSKY_SCRIPTS}/generateForDay.sh" ${NICE_ARG} --silent --timelapse "${DATE}"
     	RET=$?
     	echo -e "${ME}: ===== Timelapse complete"
    -	if [[ ${UPLOAD_VIDEO} == "true" && ${RET} = 0 ]] ; then
    +	if [[ $( settings ".timelapseupload" ) == "true" && ${RET} = 0 ]] ; then
     		"${ALLSKY_SCRIPTS}/generateForDay.sh" --upload --timelapse "${DATE}"
     	fi
     fi
     
    -# Run custom script at the end of a night. This is run BEFORE the automatic deletion
    -# just in case you need to do something with the files before they are removed
    -# TODO: remove in next release.
    -CMD="${ALLSKY_SCRIPTS}/endOfNight_additionalSteps.sh"
    -[[ -x ${CMD} ]] && "${CMD}"
    -
    -DAYS_TO_KEEP=${DAYS_TO_KEEP:-0}					# old versions allowed "" to disable
    -WEB_DAYS_TO_KEEP=${WEB_DAYS_TO_KEEP:-0}			# old versions allowed "" to disable
    -
    +DAYS_TO_KEEP="$( settings ".daystokeep" )"
     # Automatically delete old images and videos.
     if [[ ${DAYS_TO_KEEP} -gt 0 ]]; then
    -	del=$(date --date="${DAYS_TO_KEEP} days ago" +%Y%m%d)
    +	del=$( date --date="${DAYS_TO_KEEP} days ago" +%Y%m%d )
     	# "20" for years >= 2000.   Format:  YYYYMMDD
     	#                                                   YY  Y    Y   M    M   D      D
     	find "${ALLSKY_IMAGES}/" -maxdepth 1 -type d -name "20[2-9][0-9][01][0-9][0123][0-9]" | \
     		while read -r i
     
     	do
    -		if (( del > $(basename "${i}") )); then
    +		if (( del > $( basename "${i}" ) )); then
     			echo "${ME}: Deleting old directory ${i}"
     			rm -rf "${i}"
     		fi
     	done
     fi
     
    -# Automatically delete old LOCAL Website images and videos.
    +# Automatically delete old Website images and videos.
     
    -# TODO: work on remote Websites
     
    -if [[ ${WEB_DAYS_TO_KEEP} -gt 0 ]]; then
    +LOCAL_WEB_DAYS_TO_KEEP="$( settings ".daystokeeplocalwebsite" )"
    +if [[ ${LOCAL_WEB_DAYS_TO_KEEP} -gt 0 && $( settings ".uselocalwebsite" ) == "true" ]]; then
     	if [[ ! -d ${ALLSKY_WEBSITE} ]]; then
    -		echo -e "${ME}: ${YELLOW}WARNING: 'WEB_DAYS_TO_KEEP' set but no website found in '${ALLSKY_WEBSITE}!${NC}"
    -		echo -e 'Set WEB_DAYS_TO_KEEP to ""'
    +		echo -e "${ME}: ${YELLOW}WARNING: 'Days to Keep on Pi Website' set but no Local Website found in '${ALLSKY_WEBSITE}!${NC}"
    +		echo -e 'Set "Days to Keep on Pi Website" to ""'
     	else
    -		del=$(date --date="${WEB_DAYS_TO_KEEP} days ago" +%Y%m%d)
    +		del=$( date --date="${LOCAL_WEB_DAYS_TO_KEEP} days ago" +%Y%m%d )
     		(
     			cd "${ALLSKY_WEBSITE}" || exit 1
     			NUM_DELETED=0
    @@ -155,9 +143,17 @@ if [[ ${WEB_DAYS_TO_KEEP} -gt 0 ]]; then
     	fi
     fi
     
    -SHOW_ON_MAP=$(settings ".showonmap")
    -if [[ ${SHOW_ON_MAP} -eq 1 ]]; then
    -	echo -e "${ME}: ===== Posting camera details to allsky map"
    +REMOTE_WEB_DAYS_TO_KEEP="$( settings ".daystokeepremotewebsite" )"
    +if [[ ${REMOTE_WEB_DAYS_TO_KEEP} -gt 0 && $( settings ".useremotewebsite" ) == "true" ]]; then
    +	# TODO: work on remote Websites.
    +	# Possibly do a curl xxxx?keep=${REMOTE_WEB_DAYS_TO_KEEP}
    +	# and pass something so it knows this is a valid request.
    +	:
    +fi
    +
    +SHOW_ON_MAP=$( settings ".showonmap" )
    +if [[ ${SHOW_ON_MAP} == "true" ]]; then
    +	echo -e "${ME}: ===== Posting camera details to Allsky map."
     	"${ALLSKY_SCRIPTS}/postToMap.sh" --endofnight
     fi
     
    diff --git a/scripts/flow-runner.py b/scripts/flow-runner.py
    index 545829a95..14640d1d7 100755
    --- a/scripts/flow-runner.py
    +++ b/scripts/flow-runner.py
    @@ -9,6 +9,12 @@
     import numpy
     import shutil
     import time
    +import locale
    +
    +try:
    +    locale.setlocale(locale.LC_ALL, '')
    +except:
    +    pass
     
     '''
     NOTE: `valid_module_paths` must be an array, and the order specified dictates the order of search for a named module.
    @@ -31,17 +37,18 @@ def signalHandler(sig, frame):
     '''
     Get the locations of the modules and scripts and add them to the path.
     '''
    +# Can't use log() or getEnvironmentVariable() yet.
     try:
         allSkyModules = os.environ["ALLSKY_MODULE_LOCATION"]
     except KeyError:
    -    print("ERROR: $ALLSKY_MODULE_LOCATION not found in environment variables - Aborting")
    +    print("ERROR: $ALLSKY_MODULE_LOCATION not found - Aborting.")
         sys.exit(1)
     allSkyModulesLocation = os.path.join(allSkyModules, "modules")
     
     try:
         allSkyScripts = os.environ["ALLSKY_SCRIPTS"]
     except KeyError:
    -    print("ERROR: $ALLSKY_SCRIPTS not found in environment variables - Aborting")
    +    print("ERROR: $ALLSKY_SCRIPTS not found - Aborting")
         sys.exit(1)
     allSkyModulesPath = os.path.join(allSkyScripts, "modules")
     
    @@ -58,34 +65,20 @@ def signalHandler(sig, frame):
         parser.add_argument("-f", "--flowtimerframes",  type=int, help="Number of frames to capture for the flow timing averages.", default=10)
         parser.add_argument("-c", "--cleartimings", action="store_true", help="Clear any flow average timing data.")
         shared.args = parser.parse_args()
    +    #ignoreWatchdogMsg = ""
     
         shared.initDB()
     
         if shared.args.cleartimings:
             if shared.dbHasKey("flowtimer"):
                 shared.dbDeleteKey("flowtimer")
    -            
    -        try:
    -            flowTimingsFolder = os.environ["ALLSKY_FLOWTIMINGS"]
    -        except KeyError:
    -            flowTimingsFolder = os.path.join(shared.allskyTmp,"flowtimings")   
    -                        
    +
    +        flowTimingsFolder = shared.getEnvironmentVariable("ALLSKY_FLOWTIMINGS", fatal=True)
             if os.path.exists(flowTimingsFolder):
    -            shutil.rmtree(flowTimingsFolder)            
    +            shutil.rmtree(flowTimingsFolder)
             sys.exit(0)
    -        
    -    try:
    -        shared.allskyTmp = os.environ["ALLSKY_TMP"]
    -    except:
    -        shared.log(0, "ERROR: $ALLSKY_TMP not found in the variables", exitCode=1)
    -    try:
    -        imagesRoot = os.environ["ALLSKY_IMAGES"]
    -    except:
    -        shared.log(0, "ERROR: $ALLSKY_IMAGES not found in the variables", exitCode=1)
    -    try:
    -        rawSettings = os.environ["SETTINGS_FILE"]
    -    except:
    -        shared.log(0, "ERROR: no camera config file available in the environment", exitCode=1)
    +
    +    imagesRoot = shared.getEnvironmentVariable("ALLSKY_IMAGES", fatal=True);
     
         if (shared.args.event == "postcapture"):
             try:
    @@ -93,27 +86,20 @@ def signalHandler(sig, frame):
             except KeyError:
                 shared.LOGLEVEL = 0
     
    -        try:
    -            shared.CURRENTIMAGEPATH = os.environ['CURRENT_IMAGE']
    -        except KeyError:
    -            shared.log(0, "ERROR: no image file available in the environment", exitCode=1)
    -
    -        try:
    -            shared.args.tod = os.environ["DAY_OR_NIGHT"].lower()
    -        except:
    -            shared.log(0, "ERROR: unable to determine if its day or night in the environment", exitCode=1)
    +        shared.CURRENTIMAGEPATH = shared.getEnvironmentVariable("CURRENT_IMAGE", fatal=True);
    +        shared.args.tod = shared.getEnvironmentVariable("DAY_OR_NIGHT", fatal=True).lower();
     
             try:
    -            with open(rawSettings, 'r') as settingsFile:
    +            with open(shared.SETTINGS_FILE, 'r') as settingsFile:
                     shared.settings = json.load(settingsFile)
             except (FileNotFoundError, KeyError):
    -            shared.log(0, "ERROR: Unable to read SETTINGS_FILE - Aborting", exitCode=1)
    +            shared.log(0, f"ERROR: Unable to read {shared.SETTINGS_FILE} - Aborting", exitCode=1)
     
    -        shared.fullFilename = os.environ["FULL_FILENAME"]
    -        shared.createThumbnails = os.environ["IMG_CREATE_THUMBNAILS"]
    -        shared.thumbnailWidth = int(os.environ["THUMBNAIL_SIZE_X"])
    -        shared.thumbnailHeight = int(os.environ["THUMBNAIL_SIZE_Y"])
    -        shared.websiteImageFile = os.path.join(shared.allskyTmp, shared.fullFilename)
    +        shared.fullFilename = shared.getEnvironmentVariable("FULL_FILENAME", fatal=True);
    +        shared.createThumbnails = bool(shared.getSetting("imagecreatethumbnails"))
    +        shared.thumbnailWidth = int(shared.getSetting("thumbnailsizex"))
    +        shared.thumbnailHeight = int(shared.getSetting("thumbnailsizey"))
    +        shared.websiteImageFile = os.path.join(shared.ALLSKY_TMP, shared.fullFilename)
             shared.TOD = shared.args.tod
             date = datetime.now()
             if shared.args.tod == "night":
    @@ -135,149 +121,152 @@ def signalHandler(sig, frame):
             dateString = date.strftime("%Y%m%d")
             shared.imageFolder = os.path.join(imagesRoot, dateString)
     
    -    try:
    -        shared.args.allskyConfig = os.environ["ALLSKY_MODULES"]
    -    except:
    -        shared.log(0, "ERROR: no allsky config directory available in the environment", exitCode=1)
    -
    -    watchdog = False
    +    shared.args.ALLSKY_MODULES = shared.getEnvironmentVariable("ALLSKY_MODULES", fatal=True);
    +    #watchdog = False
         moduleDebug = False
         timeout = 0
         try:
    -        configFile = os.path.join(shared.args.allskyConfig, 'module-settings.json')
    +        configFile = os.path.join(shared.args.ALLSKY_MODULES, 'module-settings.json')
             with open(configFile, 'r') as module_Settings_file:
                 module_settings = json.load(module_Settings_file)
    -            watchdog = module_settings['watchdog']
    +            #watchdog = module_settings['watchdog']
                 timeout = module_settings['timeout']
                 moduleDebug = module_settings['debugmode']
         except:
    -        watchdog = False
    -        
    -    shared.args.config = rawSettings
    -    shared.log(4, "INFO: Loading config {0}".format(shared.args.config))
    +        pass
    +        #watchdog = False
    +
    +    shared.log(4, f"INFO: Loading {shared.SETTINGS_FILE}")
         try:
    -        with open(shared.args.config,'r') as config:
    +        with open(shared.SETTINGS_FILE,'r') as config:
                 try:
                     shared.conf=json.load(config)
                 except json.JSONDecodeError as err:
    -                shared.log(0, "Error: {0}".format(err), exitCode=1)
    +                shared.log(0, f"ERROR: {err}", exitCode=1)
         except:
    -        shared.log(0, "ERROR: Failed to open {0}".format(shared.args.config), exitCode=1)
    -    
    +        shared.log(0, f"ERROR: Failed to open {shared.SETTINGS_FILE}", exitCode=1)
    +
         flowName = shared.args.tod if shared.args.event == "postcapture" else shared.args.event
    -    shared.log(4, "INFO: Running {0} flow...".format(flowName))
    +    shared.log(4, f"INFO: ===== Running {flowName} flow...")
    +    moduleConfig = f"{shared.args.ALLSKY_MODULES}/postprocessing_{flowName}.json"
    +    moduleDebugFile = f"{shared.args.ALLSKY_MODULES}/postprocessing_{flowName}-debug.json"
         try:
    -        moduleConfig = "{0}/postprocessing_{1}.json".format(shared.args.allskyConfig, flowName)
    -   
             with open(moduleConfig) as flow_file:
    +            if (os.stat(moduleConfig).st_size == 0):
    +                shared.log(0, f"ERROR: File is empty: {moduleConfig}", exitCode=1)
                 try:
                     shared.flow=json.load(flow_file)
                 except json.JSONDecodeError as err:
    -                shared.log(0, "ERROR: Error parsing {0} {1}".format(moduleConfig, err), exitCode=1)
    -    except:
    -        shared.log(0, "ERROR: Failed to open {0}".format(moduleConfig), exitCode=1)
    -    
    +                shared.log(0, f"ERROR: Error parsing {moduleConfig} {err}", exitCode=1)
    +    except OSError as error:
    +        shared.log(0, f"ERROR: Failed to open {moduleConfig} {error}", exitCode=1)
    +
         if (shared.args.event == "postcapture"):
    -        disableFile = os.path.join(shared.allskyTmp,"disable")
    +        disableFile = os.path.join(shared.ALLSKY_TMP,"disable")
             if shared.isFileReadable(disableFile):
                 with open(disableFile, "r") as fp:
                     disable = json.load(fp)
                     for module in disable:
                         moduleName = disable[module].replace('.py','')
    -                    method = disable[module].replace('.py','').replace('allsky_','') + "_cleanup"
    +                    method = moduleName.replace('allsky_','') + "_cleanup"
                         _temp = importlib.import_module(moduleName)
                         if hasattr(_temp, method):
                             globals()[method] = getattr(_temp, method)
                             result = globals()[method]()
    -                        shared.log(4, "INFO: Cleared module data for {0}".format(moduleName))
    +                        shared.log(4, f"INFO: Cleared module data for {moduleName}")
                         else:
    -                        shared.log(3, "INFO: Attempting to clear module data for {0} but no function provided".format(moduleName))
    -                        
    +                        shared.log(3, f"WARNING: Attempted to clear module data for {moduleName} but no function provided.")
    +
                 os.remove(disableFile)
    -    
    +
         results = {}
         if moduleDebug:
             flowStartTime = round(time.time() * 1000)
         for shared.step in shared.flow:
    -        if shared.flow[shared.step]["enabled"] and shared.flow[shared.step]["module"] not in globals():
    +        fileName = shared.flow[shared.step]['module']
    +        enabled = shared.flow[shared.step]["enabled"]
    +        if enabled and fileName not in globals():
                 try:
    -                moduleName = shared.flow[shared.step]['module'].replace('.py','')
    -                method = shared.flow[shared.step]['module'].replace('.py','').replace('allsky_','')
    -                shared.log(4, "INFO: ----------------------- Running Module {0} -----------------------".format(shared.flow[shared.step]['module']))
    -                shared.log(4, "INFO: Attempting to load {0}".format(moduleName))
    +                moduleName = fileName.replace('.py','')
    +                method = moduleName.replace('allsky_','')
    +                shared.log(4, f"INFO: --------------- Running Module {moduleName} ---------------")
                     _temp = importlib.import_module(moduleName)
                     globals()[method] = getattr(_temp, method)
                 except Exception as e:
    -                shared.log(0, "ERROR: Failed to import module allsky_{0}.py in one of ( {1} ). Ignoring Module.".format(moduleName, e))
    +                shared.log(0, f"ERROR: Failed to import module {moduleName}.py: {e}; ignoring.")
             else:
    -            shared.log(4, "INFO: Ignorning module {0} as its disabled".format(shared.flow[shared.step]["module"]))
    +            shared.log(4, f"INFO: Module {fileName} disabled; ignoring.")
     
    -        if shared.flow[shared.step]["enabled"] and method in globals():
    +        if enabled and method in globals():
                 startTime = datetime.now()
                 result = False
    -            
    +
                 arguments = {}
                 if 'arguments' in shared.flow[shared.step]['metadata']:
                     arguments = shared.flow[shared.step]['metadata']['arguments']
    -                
    +
                 try:
                     result = globals()[method](arguments, shared.args.event)
                 except Exception as e:
                     eType, eObject, eTraceback = sys.exc_info()
    -                shared.log(0, f"ERROR: Module {shared.flow[shared.step]['module']} failed on line {eTraceback.tb_lineno} - {e}")
    +                shared.log(0, f"ERROR: Module {fileName} failed on line {eTraceback.tb_lineno} - {e}")
     
                 endTime = datetime.now()
                 elapsedTime = (((endTime - startTime).total_seconds()) * 1000) / 1000
     
    -            ignoreWatchdog = False
    -            if shared.step in ['loadimage','saveimage']:
    -                 ignoreWatchdog = True
    -            else:
    -                if 'ignorewatchdog' in shared.flow[shared.step]['metadata']:
    -                    if shared.flow[shared.step]['metadata']['ignorewatchdog']:
    -                        ignoreWatchdog = True
    -                    
    +            #ignoreWatchdog = False
    +            #if shared.step in ['loadimage','saveimage']:
    +            #     ignoreWatchdog = True
    +            #else:
    +            #    if 'ignorewatchdog' in shared.flow[shared.step]['metadata']:
    +            #        if shared.flow[shared.step]['metadata']['ignorewatchdog']:
    +            #            ignoreWatchdog = True
    +
                 results[shared.step] = {}
    -            if not ignoreWatchdog:
    -                if watchdog:
    -                    if elapsedTime > timeout:
    -                        shared.log(0, 'ERROR: Module {0} will be disabled, it took {1:.2f}s max allowed is {2}s'.format(shared.flow[shared.step]['module'], elapsedTime, timeout))
    -                        results[shared.step]["disable"] = True
    -                    else:
    -                        shared.log(4, 'INFO: Module {0} ran ok in {1:.2f}s'.format(shared.flow[shared.step]['module'], elapsedTime))
    -                else:
    -                    shared.log(4, 'INFO: Module {0} ran ok in {1:.2f}s'.format(shared.flow[shared.step]['module'], elapsedTime))
    -            else:
    -                shared.log(4, f'INFO: Ignoring watchdog for module {shared.step}')             
    -                    
    -            results[shared.step]["lastexecutiontime"] = str(elapsedTime) 
    +            #if not ignoreWatchdog:
    +            #    if watchdog:
    +            #        if elapsedTime > timeout:
    +            #            shared.log(0, f'ERROR: Module {fileName} will be disabled, it took {elapsedTime:.2f} seconds; max allowed is {timeout} seconds')
    +            #            results[shared.step]["disable"] = True
    +            #        else:
    +            #            shared.log(4, f'INFO: Module {fileName} ran ok in {elapsedTime:.2f} seconds')
    +            #    else:
    +            #        shared.log(4, f'INFO: Module {fileName} ran ok in {elapsedTime:.2f} seconds')
    +            #else:
    +            #    ignoreWatchdogMsg = ignoreWatchdogMsg + f"  {shared.step}"
    +
    +            results[shared.step]["lastexecutiontime"] = str(elapsedTime)
     
                 if result == shared.ABORT:
                     break
     
                 results[shared.step]["lastexecutionresult"] = result
    -    shared.log(4, "INFO: {0} flow Complete...".format(flowName))
     
    -    with open(moduleConfig) as updatefile:
    -        try:
    -            config = json.load(updatefile)
    -            for step in config:
    -                if step in results:
    -                    config[step]["lastexecutiontime"] = results[step]["lastexecutiontime"]
    -                    config[step]["lastexecutionresult"] = results[step]["lastexecutionresult"]
    -                    if "disable" in results[step]:
    -                        config[step]["enabled"] = False
    -
    -            updatefile.close()
    -            with open(moduleConfig, "w") as updatefile:
    -                json.dump(config, updatefile, indent=4)
    -        except json.JSONDecodeError as err:
    -            shared.log(0, "ERROR: Error parsing {0} {1}".format(moduleConfig, err), exitCode=1)
    -
    -    if moduleDebug:        
    +    #if ignoreWatchdogMsg != "":
    +    #    shared.log(4, f'INFO: Ignored watchdog for: {ignoreWatchdogMsg}')
    +    shared.log(4, f"INFO: ===== {flowName} flow complete.")
    +
    +    try:
    +        debugData = {}
    +        for step in results:
    +            if step not in debugData:
    +                debugData[step] = {}
    +                
    +            debugData[step]["lastexecutiontime"] = results[step]["lastexecutiontime"]
    +            debugData[step]["lastexecutionresult"] = results[step]["lastexecutionresult"]
    +            if "disable" in results[step]:
    +                debugData[step]["enabled"] = False
    +                                                
    +        with open(moduleDebugFile, "w+") as debugFile:
    +            json.dump(debugData, debugFile, indent=4)
    +    except Exception as err:
    +        shared.log(0, f"ERROR: Error saving module debug data {err}", exitCode=1) 
    +        
    +    flowTimingsFolder = shared.getEnvironmentVariable("ALLSKY_FLOWTIMINGS", fatal=True)
    +    if moduleDebug:
             try:
                 flowTimingsFile = os.environ[f"ALLSKY_FLOWTIMINGS_{flowName.upper()}"]
    -        
    +
                 flowEndTime = round(time.time() * 1000)
                 flowElapsedTime = int(flowEndTime - flowStartTime)
                 queueData = []
    @@ -286,37 +275,28 @@ def signalHandler(sig, frame):
                     allQueueData = shared.dbGet("flowtimer")
                     if flowName in allQueueData:
                         queueData = allQueueData[flowName]
    -                
    +
                 queue = deque(queueData, maxlen = shared.args.flowtimerframes)
                 queue.append(flowElapsedTime)
    -            
    +
                 queueData = list(queue)
                 allQueueData[flowName] = queueData
                 shared.dbUpdate("flowtimer", allQueueData)
    -            
    -            try:
    -                flowTimingsFolder = os.environ["ALLSKY_FLOWTIMINGS"]
    -            except KeyError:
    -                flowTimingsFolder = os.path.join(shared.allskyTmp,"flowtimings")
    -            
    +
                 shared.checkAndCreateDirectory(flowTimingsFolder)
                 if len(list(queue)) >= shared.args.flowtimerframes:
                     average = str(int(numpy.average(list(queue))))
                     with open(flowTimingsFile, 'w') as f:
    -                    f.write(average) 
    +                    f.write(average)
                 else:
                     if shared.isFileWriteable(flowTimingsFile):
                         os.remove(flowTimingsFile)
             except KeyError:
                 pass
    -            
    -    if not moduleDebug:
    -        try:
    -            flowTimingsFolder = os.environ["ALLSKY_FLOWTIMINGS"]
    -        except KeyError:
    -            flowTimingsFolder = os.path.join(shared.allskyTmp,"flowtimings")        
    +
    +    else:
             if shared.dbHasKey("flowtimer"):
                 shared.dbDeleteKey("flowtimer")
    -            
    +
             if os.path.exists(flowTimingsFolder):
    -            shutil.rmtree(flowTimingsFolder)
    \ No newline at end of file
    +            shutil.rmtree(flowTimingsFolder)
    diff --git a/scripts/flowupgrade.py b/scripts/flowupgrade.py
    index cf7688a1d..bad297930 100755
    --- a/scripts/flowupgrade.py
    +++ b/scripts/flowupgrade.py
    @@ -20,14 +20,14 @@ def __init__(self, args):
             try:
                 allSkyModules = os.environ["ALLSKY_MODULE_LOCATION"]
             except KeyError:
    -            print("ERROR: $ALLSKY_MODULE_LOCATION not found in environment variables - Aborting")
    +            print("ERROR: $ALLSKY_MODULE_LOCATION not found - Aborting")
                 sys.exit(1)
             allSkyModulesLocation = os.path.join(allSkyModules, "modules")
     
             try:
                 allSkyScripts = os.environ["ALLSKY_SCRIPTS"]
             except KeyError:
    -            print("ERROR: $ALLSKY_SCRIPTS not found in environment variables - Aborting")
    +            print("ERROR: $ALLSKY_SCRIPTS not found - Aborting")
                 sys.exit(1)
             allSkyModulesPath = os.path.join(allSkyScripts, "modules")
     
    diff --git a/scripts/functions.sh b/scripts/functions.sh
    index 066d9e31e..f7bc21e71 100644
    --- a/scripts/functions.sh
    +++ b/scripts/functions.sh
    @@ -1,9 +1,50 @@
     #!/bin/bash
     
     # Shell functions used by multiple scripts.
    -# This file is "source"d into others, and must be done AFTER source'ing variables.sh
    -# and config.sh.
    -
    +# This file is "source"d into others, and must be done AFTER source'ing variables.sh.
    +
    +SUDO_OK="${SUDO_OK:-false}"
    +if [[ ${SUDO_OK} == "false" && ${EUID} -eq 0 ]]; then
    +	echo -e "\n${RED}${ME}: This script must NOT be run as root, do NOT use 'sudo'.${NC}\n" >&2
    +	exit 1
    +fi
    +
    +# Globals
    +ZWO_VENDOR="03c3"
    +# shellcheck disable=SC2034
    +NOT_STARTED_MSG="Can't start Allsky!"
    +STOPPED_MSG="Allsky Stopped!"
    +ERROR_MSG_PREFIX="*** ERROR ***\n${STOPPED_MSG}\n"
    +FATAL_MSG="FATAL ERROR:"
    +if [[ ${ON_TTY} == "true" ]]; then
    +	export NL="\n"
    +	export SPACES="    "
    +	export STRONGs=""
    +	export STRONGe=""
    +	export WSNs="'"
    +	export WSNe="'"
    +	export WSVs=""
    +	export WSVe=""
    +else
    +	export NL="<br>"
    +	export SPACES="&nbsp; &nbsp; &nbsp;"
    +	export STRONGs="<strong>"
    +	export STRONGe="</strong>"
    +	export WSNs="<span class='WebUISetting'>"		# Web Setting Name start
    +	export WSNe="</span>"
    +	export WSVs="<span class='WebUIValue'>"		# Web Setting Value start
    +	export WSVe="</span>"
    +fi
    +
    +##### Start and Stop Allsky
    +function start_Allsky()
    +{
    +	sudo systemctl start allsky 2> /dev/null
    +}
    +function stop_Allsky()
    +{
    +	sudo systemctl stop allsky 2> /dev/null
    +}
     
     #####
     # Exit with error message and a custom notification image.
    @@ -14,23 +55,37 @@ function doExit()
     	local CUSTOM_MESSAGE="${3}"		# optional
     	local WEBUI_MESSAGE="${4}"		# optional
     
    -	case "${TYPE}" in
    -		"Warning")
    +	local COLOR=""  OUTPUT_A_MSG
    +	local MSG_TYPE="${TYPE}"
    +
    +	case "${TYPE,,}" in
    +		"no-image")
    +			COLOR="green"
    +			;;
    +		"success")
    +			COLOR="green"
    +			;;
    +		"warning" | "info" | "debug")
     			COLOR="yellow"
     			;;
    -		"Error")
    +		"error")
     			COLOR="red"
     			;;
    -		"NotRunning" | *)
    +		"notrunning")
     			COLOR="yellow"
     			;;
    +		*)
    +			# ${TYPE} is the name of a notification image so
    +			# assume it's for an error.
    +			COLOR="red"
    +			MSG_TYPE="Error"
    +			;;
     	esac
     
     	OUTPUT_A_MSG="false"
     	if [[ -n ${WEBUI_MESSAGE} ]]; then
    -		[[ ${TYPE} = "no-image" ]] && TYPE="success"
    -		"${ALLSKY_SCRIPTS}/addMessage.sh" "${TYPE}" "${WEBUI_MESSAGE}"
    -		echo "Stopping Allsky: ${WEBUI_MESSAGE}"
    +		"${ALLSKY_SCRIPTS}/addMessage.sh" "${MSG_TYPE}" "${WEBUI_MESSAGE}"
    +		echo -e "Stopping Allsky: ${WEBUI_MESSAGE}" >&2
     		OUTPUT_A_MSG="true"
     	fi
     
    @@ -39,85 +94,387 @@ function doExit()
     		# even if the user has them turned off.
     		if [[ -n ${CUSTOM_MESSAGE} ]]; then
     			# Create a custom error message.
    -			# If we error out before config.sh is sourced in, $FILENAME and $EXTENSION won't be
    -			# set so guess at what they are.
    +			# If we error out before variables.sh is sourced in,
    +			# ${FILENAME} and ${EXTENSION} won't be set so guess at what they are.
     			"${ALLSKY_SCRIPTS}/generate_notification_images.sh" --directory "${ALLSKY_TMP}" \
     				"${FILENAME:-"image"}" \
     				"${COLOR}" "" "85" "" "" \
     				"" "10" "${COLOR}" "${EXTENSION:-"jpg"}" "" "${CUSTOM_MESSAGE}"
    +			echo "Stopping Allsky: ${CUSTOM_MESSAGE}"
     		elif [[ ${TYPE} != "no-image" ]]; then
     			[[ ${OUTPUT_A_MSG} == "false" && ${TYPE} == "RebootNeeded" ]] && echo "Reboot needed"
     			"${ALLSKY_SCRIPTS}/copy_notification_image.sh" --expires 0 "${TYPE}" 2>&1
     		fi
     	fi
     
    -	echo "     ***** AllSky Stopped *****"
    +	echo "     ***** AllSky Stopped *****" >&2
     
     	# Don't let the service restart us because we'll likely get the same error again.
    -	[[ ${EXITCODE} -ge ${EXIT_ERROR_STOP} ]] && sudo systemctl stop allsky
    +	# Stop here so the message above is output first.
    +	[[ ${EXITCODE} -ge ${EXIT_ERROR_STOP} ]] && stop_Allsky
    +
    +	exit "${EXITCODE}"
    +}
    +
    +
    +#####
    +# This allows testing from the command line without "return" or doExit() killing the shell.
    +function test_verify_CAMERA_TYPE()
    +{
    +	# "true" == ignore errors
    +	verify_CAMERA_TYPE "${1}" "true" || echo -e "\nverify_CAMERA_TYPE() returned $?"
    +}
    +#####
    +# Make sure the CAMERA_TYPE is valid.
    +# This should never happen unless something got corrupted.
    +# Exit on error.
    +function verify_CAMERA_TYPE()
    +{
    +	local CT="${1}"
    +	local IGNORE_ERRORS="${2:-false}"
    +
    +	local OK  MSG  IMAGE_MSG
    +
    +	OK="true"
    +	if [[ -z ${CT} ]]; then
    +		OK="false"
    +		MSG="'Camera Type' not set in WebUI."
    +		IMAGE_MSG="${ERROR_MSG_PREFIX}\nCamera Type\nnot specified."
    +
    +	elif [[ ${CT} != "RPi" && ${CT} != "ZWO" ]]; then
    +		OK="false"
    +		MSG="Unknown Camera Type: ${CT}."
    +		IMAGE_MSG="${ERROR_MSG_PREFIX}\nCamera Type\nnot specified."
    +	fi
    +
    +	if [[ ${OK} == "false" ]]; then
    +		echo -e "${RED}${FATAL_MSG} ${MSG}${NC}" >&2
    +
    +		if [[ ${IGNORE_ERRORS} != "true" ]]; then
    +			doExit "${EXIT_NO_CAMERA}" "Error" "${IMAGE_MSG}" "${MSG}"
    +		fi
    +
    +		return 1
    +	fi
     
    -	# shellcheck disable=SC2086
    -	exit ${EXITCODE}
    +	return 0
     }
     
    +#####
    +# This allows testing from the command line without "return" or doExit() killing the shell.
    +function test_determineCommandToUse()
    +{
    +	# true == ignore errors
    +	determineCommandToUse "${1}" "${2}" "true" || echo -e "\ndetermineCommandToUse() returned $?"
    +}
     
     #####
    -# RPi cameras can use either "raspistill" on Buster or "libcamera-still" on Bullseye
    -# to actually take pictures.
    +# RPi cameras can use either "raspistill" on Buster or "{rpicam|libcamera}-still" on newer
    +# OS's to actually take pictures.
     # Determine which to use.
    -# On success, return 1 and the command to use.
    -# On failure, return 0 and an error message.
    +# On success, return 0 and the command to use.
    +# On failure, return non-0 and an error message.
    +CMD_TO_USE_=""
     function determineCommandToUse()
     {
    -	local USE_doExit="${1}"			# Call doExit() on error?
    -	local PREFIX="${2}"				# only used if calling doExit()
    +	# If we were already called just return the command.
    +	if [[ -n ${CMD_TO_USE_} ]]; then
    +		echo "${CMD_TO_USE_}"
    +		return 0
    +	fi
    +
    +	local USE_doExit="${1:-false}"		# Call doExit() on error?
    +	local PREFIX="${2}"					# Only used if calling doExit().
    +	local IGNORE_ERRORS="${3:-false}"	# True if just checking
    +
    +	local CRET  RET  MSG  EXIT_MSG
     
     	# If libcamera is installed and works, use it.
     	# If it's not installed, or IS installed but doesn't work (the user may not have it configured),
     	# use raspistill.
     
    -	local RET=1
    -	local CMD="libcamera-still"
    -	if command -v ${CMD} > /dev/null; then
    +	RET=1
    +	CMD_TO_USE_="rpicam-still"
    +	command -v "${CMD_TO_USE_}" > /dev/null
    +	CRET=$?
    +	if [[ ${CRET} -ne 0 ]]; then
    +		CMD_TO_USE_="libcamera-still"
    +		command -v "${CMD_TO_USE_}" > /dev/null
    +		CRET=$?
    +	fi
    +	if [[ ${CRET} -eq 0 ]]; then
     		# Found the command - see if it works.
    -		"${CMD}" --timeout 1 --nopreview > /dev/null 2>&1
    +		"${CMD_TO_USE_}" --timeout 1 --nopreview > /dev/null 2>&1
     		RET=$?
     		if [[ ${RET} -eq 137 ]]; then
    -			# If another libcamera-still is running the one we execute will hang for
    +			# If another of these commands is running ours will hang for
     			# about a minute then be killed with RET=137.
    -			# If that happens, assume libcamera-still is the command to use.
    +			# If that happens, assume this is the command to use.
     			RET=0
     		fi
     	fi
     
     	if [[ ${RET} -ne 0 ]]; then
    -		# Didn't find libcamera-still, or it didn't work.
    -
    -		CMD="raspistill"
    -		if ! command -v "${CMD}" > /dev/null; then
    -			echo "Can't determine what command to use for RPi camera." >&2
    -			if [[ ${USE_doExit} == "true" ]]; then
    -				doExit "${EXIT_ERROR_STOP}" "Error" "${PREFIX}\nRPi camera command\nnot found!."
    +		# Didn't find libcamera-based command, or it didn't work.
    +		CMD_TO_USE_="raspistill"
    +		if ! command -v "${CMD_TO_USE_}" > /dev/null; then
    +			CMD_TO_USE_=""
    +
    +			if [[ ${IGNORE_ERRORS} == "false" ]]; then
    +				MSG="Can't determine what command to use for RPi camera."
    +				echo "${MSG}" >&2
    +
    +				if [[ ${USE_doExit} == "true" ]]; then
    +					EXIT_MSG="${PREFIX}\nRPi camera command\nnot found!."
    +					doExit "${EXIT_ERROR_STOP}" "Error" "${EXIT_MSG}" "${MSG}"
    +				fi
     			fi
     
     			return 1
     		fi
     
    -		"${CMD}" --timeout 1 --nopreview # > /dev/null 2>&1
    -		RET=$?
    +		# On Buster, raspistill sometimes hangs if no camera is found,
    +		# so work around that.
    +		if ! timeout 4 "${CMD_TO_USE_}" --timeout 1 --nopreview > /dev/null 2>&1 ; then
    +			CMD_TO_USE_=""
    +
    +			if [[ ${IGNORE_ERRORS} == "false" ]]; then
    +				MSG="RPi camera not found.  Make sure it's enabled."
    +				echo "${MSG}" >&2
    +
    +				if [[ ${USE_doExit} == "true" ]]; then
    +					EXIT_MSG="${PREFIX}\nRPi camera\nnot found!\nMake sure it's enabled."
    +					doExit "${EXIT_ERROR_STOP}" "Error" "${EXIT_MSG}" "${MSG}"
    +				fi
    +			fi
    +
    +			return "${EXIT_NO_CAMERA}"
    +		fi
     	fi
     
    -	if [[ ${RET} -ne 0 ]]; then
    -		echo "RPi camera not found.  Make sure it's enabled." >&2
    -		if [[ ${USE_doExit} == "true" ]]; then
    -			doExit "${EXIT_NO_CAMERA}" "Error" "${PREFIX}\nRPi camera\nnot found!\nMake sure it's enabled."
    +	echo "${CMD_TO_USE_}"
    +	return 0
    +}
    +
    +#####
    +# Get information on the connected camera(s), one line per camera.
    +# Prepend each line with the CAMERA_TYPE.
    +function get_connected_cameras_info()
    +{
    +	local IGNORE_ERRORS="${1:-false}"
    +
    +	####### Check for RPi
    +	# Tab-separated output will be:
    +	#		RPi  camera_number   camera_sensor
    +	# for each camera found.
    +	# camera_sensor will be one word.
    +	if [[ -z ${CMD_TO_USE_} ]]; then
    +		determineCommandToUse "false" "" "${IGNORE_ERRORS}" > /dev/null
    +	fi
    +	if [[ -n ${CMD_TO_USE_} ]]; then
    +		if [[ ${CMD_TO_USE_} == "raspistill" ]]; then
    +			# Only supported camera with raspistill
    +			echo -e "RPi\t0\timx477"
    +
    +		else
    +			# Input:
    +			#	camera_number  : sensor  [other stuff]
    +			LIBCAMERA_LOG_LEVELS=FATAL "${CMD_TO_USE_}" --list-cameras 2>&1 |
    +				gawk '/^[0-9]/ { printf("%s\t%d\t%s\n", "RPi", $1, $3); }'
     		fi
    +	fi
    +
    +	####### Check for ZWO
    +	# Keep output similar to RPi:
    +	#		ZWO  camera_number camera_model
    +	# for each camera found.
    +# TODO: Is the order they appear from lsusb the same as the camera number?
    +	# lsusb output:
    +	#	Bus 002 Device 002: ID 03c3:290b				(Buster)
    +	#		iProduct 2 ASI290MM
    +	#	Bus 002 Device 002: ID 03c3:290b ZWO ASI290MM	(newer OS)
    +	#	1   2   3       4   5  6         7   8
    +	# or, for really old cameras:
    +	#	Bus 001 Device 002: ID 03c3:120b ZWOptical company   ASI120MC
    +	#	1   2   3       4   5  6         7         8         9
    +	lsusb -d "${ZWO_VENDOR}:" --verbose 2>/dev/null |
    +	gawk 'BEGIN { num = 0; model = ""; }
    +		{
    +			if ($1 == "Bus" && $3 == "Device") {
    +				ZWO = $7;
    +				if (ZWO == "ZWOptical" && $8 == "company") {
    +					model = $9;
    +					model_cont = 10;
    +				} else {
    +					model = $8;
    +					model_cont = 9;
    +				}
    +				if (model != "") {
    +					# The model may have multiple tokens.
    +					for (i=model_cont; i<= NF; i++) model = model " " $i
    +					printf("ZWO\t%d\t%s\n", num++, model);
    +					model = "<found>";		# This camera was output
    +				}
    +			} else if ($1 == "iProduct" && $3 != "(error)") {
    +				if (model != "<found>") {
    +					model = $3;
    +					for (i=4; i<= NF; i++) model = model " " $i
    +					printf("ZWO\t%d\t%s\n", num++, model);
    +				}
    +				model = "";		# This camera was output
    +			}
    +		}'
    +}
    +
    +
    +#####
    +# Get just the model name(s) of the specified camera type that are connected to the Pi.
    +function get_connected_camera_models()
    +{
    +	local FULL="false"
    +	[[ ${1} == "--full" ]] && FULL="true" && shift
     
    -		return "${EXIT_NO_CAMERA}"
    +	local TYPE="${1}"
    +	if [[ -z ${TYPE} ]]; then
    +		echo "Usage: ${FUNCNAME[0]} type" >&2
    +		return 1
     	fi
     
    -	echo "${CMD}"
    -	return 0
    +
    +	# Input:
    +	#		ZWO  camera_number  camera_model
    +	#		RPi  camera_number  camera_sensor
    +
    +	# Output (tab-separated):
    +	#	Short:
    +	#		camera_model
    +	#	FULL:
    +	#		ZWO  camera_number  camera_model
    +	#		RPi  camera_number  camera_model  camera_sensor
    +	#		1    2              3             4
    +	#		1    2              3             4
    +
    +	# For RPi we have the sensor and need the model.
    +	local PATH="${PATH}:${ALLSKY_UTILITIES}"
    +	gawk -v TYPE="${TYPE}" -v FULL="${FULL}" --field-separator="\t" '
    +		{
    +			camera_type = $1;
    +			if (camera_type != TYPE && TYPE != "both") next;
    +
    +			if (camera_type == "ZWO") {
    +				if (FULL == "true") {
    +					print $0;
    +				} else {
    +					model = $3;
    +					print model;
    +				}
    +			} else {
    +				sensor = $3;
    +				"get_model_from_sensor.sh " sensor | getline model;
    +				if (FULL == "true") {
    +					printf("%s\t%d\t%s\t%s\n", $1, $2, model, sensor);
    +				} else {
    +					print model;
    +				}
    +			}
    +		}' "${CONNECTED_CAMERAS_INFO}"
    +}
    +
    +
    +#####
    +# This allows testing from the command line without "return" or doExit() killing the shell.
    +function test_validate_camera()
    +{
    +	# true == ignore errors
    +	validate_camera "${1}" "${2}" "${3}" "true" || echo -e "\nvalidate_camera() returned $?"
    +}
    +
    +#####
    +# Check if the current camera is known (i.e., supported by Allsky) and is
    +# different from the last camera used (i.e., the user changed cameras without telling Allsky).
    +function validate_camera()
    +{
    +	local CT="${1}"		# Camera type
    +	local CM="${2}"		# Camera model
    +	local CN="${3}"		# Camera number
    +	if [[ $# -lt 3 ]]; then
    +		echo -e "\n${RED}Usage: ${FUNCNAME[0]} camera_type camera_model camera_number${NC}\n" >&2
    +		return 2
    +	fi
    +	local IGNORE_ERRORS="${4:-false}"	# True if just checking
    +
    +	verify_CAMERA_TYPE "${CT}" "${IGNORE_ERRORS}" || return 2
    +
    +	local MSG  URL  RET
    +
    +	# Compare the specified camera to what's in the settings file.
    +	SETTINGS_CT="$( settings ".cameratype" )"
    +	SETTINGS_CM="$( settings ".cameramodel" )"
    +	SETTINGS_CN="$( settings ".cameranumber" )"
    +
    +	RET=0
    +	if [[ ${SETTINGS_CT} != "${CT}" ]]; then
    +		MSG="The Camera Type unexpectedly changed from '${SETTINGS_CT}' to '${CT}'."
    +		MSG+="\nGo to the 'Allsky Settings' page of the WebUI and"
    +		MSG+="\nchange the 'Camera Type' to 'Refresh' then save the settings."
    +		if [[ ${ON_TTY} == "true" ]]; then
    +			echo -e "\n${RED}${MSG}${NC}\n"
    +		else
    +			URL="/index.php?page=configuration"
    +			"${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${MSG}" "${URL}"
    +		fi
    +		RET=1
    +	elif [[ ${SETTINGS_CM} != "${CM}" ]]; then
    +		MSG="The Camera Model unexpectedly changed from '${SETTINGS_CM}' to '${CM}'."
    +		MSG+="\nGo to the 'Allsky Settings' page of the WebUI and"
    +		MSG+="\nchange the 'Camera Model' to '${CM}' then save the settings."
    +		if [[ ${ON_TTY} == "true" ]]; then
    +			echo -e "\n${RED}${MSG}${NC}\n"
    +		else
    +			URL="/index.php?page=configuration"
    +			"${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${MSG}" "${URL}"
    +		fi
    +		RET=1
    +	elif [[ ${SETTINGS_CN} != "${CN}" ]]; then
    +		MSG="The camera's number unexpectedly changed from '${SETTINGS_CN}' to '${CN}'."
    +		MSG+="\nGo to the 'Allsky Settings' page of the WebUI and"
    +		MSG+="\nchange the 'Camera Type' to 'Refresh' then save the settings."
    +		if [[ ${ON_TTY} == "true" ]]; then
    +			echo -e "\n${RED}${MSG}${NC}\n"
    +		else
    +			URL="/index.php?page=configuration"
    +			"${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${MSG}" "${URL}"
    +		fi
    +		RET=1
    +	fi
    +
    +	if [[ ${CT} == "ZWO" ]]; then
    +		# The camera name per the camera may have "-" in it,
    +		# but the list of ZWO cameras has "_" instead.
    +		CM="${CM/ASI/}"		# "ASI" isn't in the names
    +		CM="${CM//-/_}"
    +	fi
    +
    +	# Now make sure the camera is supported.
    +	if ! "${ALLSKY_UTILITIES}/show_supported_cameras.sh" "--${CT}" |
    +		grep --silent "${CM}" ; then
    +
    +		MSG="${CT} camera model '${CM}' is not supported by Allsky."
    +		MSG+="\nTo see the list of supported ${CT} cameras, run"
    +		MSG+="\n    show_supported_cameras.sh --${CT}"
    +		[[ ${CT} == "ZWO" ]] && MSG+="\nWARNING: the list is long!"
    +		if [[ ${ON_TTY} == "true" ]]; then
    +			echo -e "\n${RED}${MSG}${NC}\n"
    +		else
    +			MSG+="\n\nClick this message to ask that Allsky support this camera."
    +			URL="/documentation/explanations/requestCameraSupport.html";
    +			"${ALLSKY_SCRIPTS}/addMessage.sh" "warning" "${MSG}" "${URL}"
    +		fi
    +
    +		return 2
    +	fi
    +
    +	return "${RET}"
     }
     
     
    @@ -150,7 +507,7 @@ function getJSONarrayIndex()
     function convertLatLong()
     {
     	local LATLONG="${1}"
    -	local TYPE="${2}"						# "latitude" or "longitude"
    +	local TYPE="${2^}"						# "Latitude" or "Longitude"
     	LATLONG="${LATLONG^^[nsew]}"			# convert any character to uppercase for consistency
     	local SIGN="${LATLONG:0:1}"				# First character, may be "-" or "+" or a number
     	local DIRECTION="${LATLONG: -1}"						# May be N, S, E, or W, or a number
    @@ -161,27 +518,39 @@ function convertLatLong()
     		# No direction
     		if [[ -z ${SIGN} ]]; then
     			# No sign either
    -			EMSG="ERROR: '${TYPE}' should contain EITHER a '+' or '-', OR a"
    -			if [[ ${TYPE} == "latitude" ]]; then
    -				EMSG="${EMSG} 'N' or 'S'"
    +			EMSG="ERROR: ${TYPE} (${LATLONG}) should contain EITHER a '+' or '-', OR a"
    +			if [[ ${TYPE} == "Latitude" ]]; then
    +				EMSG+=" 'N' or 'S'"
     			else
    -				EMSG="${EMSG} 'E' or 'W'"
    +				EMSG+=" 'E' or 'W'"
     			fi
    -			EMSG="${EMSG}; you entered '${LATLONG}'."
     			echo -e "${EMSG}" >&2
     			return 1
     		fi
     
    -		# A number - convert to character
    +		# A number.
    +	   
    +		# Make sure it's a valid number.
    +		if ! is_number "${LATLONG}" ; then
    +			EMSG="ERROR: ${TYPE} (${LATLONG}) is an invalid number. It should only contain:"
    +			EMSG+="\n  * Zero or one of EITHER '+' OR '-' at the beginning of the number"
    +			EMSG+="\n  * One or more of the digits 1 - 9"
    +			EMSG+="\n  * Zero or one '.'"
    +			[[ ${LATLONG} =~ "," ]] && EMSG+=" (commas (',') are not allowed)"
    +			echo -e "${EMSG}" >&2
    +			return 1
    +		fi
    +
    +		# Convert to String with NSEW
     		LATLONG="${LATLONG:1}"		# Skip over sign
     		if [[ ${SIGN} == "+" ]]; then
    -			if [[ ${TYPE} == "latitude" ]]; then
    +			if [[ ${TYPE} == "Latitude" ]]; then
     				echo "${LATLONG}N"
     			else
     				echo "${LATLONG}E"
     			fi
     		else
    -			if [[ ${TYPE} == "latitude" ]]; then
    +			if [[ ${TYPE} == "Latitude" ]]; then
     				echo "${LATLONG}S"
     			else
     				echo "${LATLONG}W"
    @@ -190,19 +559,21 @@ function convertLatLong()
     		return 0
     
     	elif [[ -n ${SIGN} ]]; then
    -		echo "'${TYPE}' should contain EITHER a '${SIGN}' OR a '${DIRECTION}', but not both; you entered '${LATLONG}'." >&2
    +		EMSG="ERROR: ${TYPE} (${LATLONG}) should contain EITHER a '${SIGN}' OR a '${DIRECTION}',"
    +		EMSG+=" but not both."
    +		echo -e "${EMSG}" >&2
     		return 1
     
     	else
     		# There's a direction - make sure it's valid for the TYPE.
    -		if [[ ${TYPE} == "latitude" ]]; then
    +		if [[ ${TYPE} == "Latitude" ]]; then
     			if [[ ${DIRECTION} != "N" && ${DIRECTION} != "S" ]]; then
    -				echo "'${TYPE}' should contain a 'N' or 'S' ; you entered '${LATLONG}'." >&2
    +				echo "ERROR: ${TYPE} (${LATLONG}) should contain a 'N' or 'S'." >&2
     				return 1
     			fi
     		else
     			if [[ ${DIRECTION} != "E" && ${DIRECTION} != "W" ]]; then
    -				echo "'${TYPE}' should contain an 'E' or 'W' ; you entered '${LATLONG}'." >&2
    +				echo "ERROR: ${TYPE} (${LATLONG}) should contain an 'E' or 'W'." >&2
     				return 1
     			fi
     		fi
    @@ -220,27 +591,39 @@ function convertLatLong()
     # to allow testing various configurations.
     function get_sunrise_sunset()
     {
    +	local DO_ZERO="false"
    +	if [[ ${1} == "--zero" ]]; then
    +		DO_ZERO="true"
    +		shift
    +	fi
    +
     	local ANGLE="${1}"
     	local LATITUDE="${2}"
     	local LONGITUDE="${3}"
    -	#shellcheck disable=SC2086 source-path=.
    +	#shellcheck source-path=.
     	source "${ALLSKY_HOME}/variables.sh"	|| return 1
    -	#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -	source "${ALLSKY_CONFIG}/config.sh"		|| return 1
    -
    -	[[ -z ${ANGLE} ]] && ANGLE="$(settings ".angle")"
    -	[[ -z ${LATITUDE} ]] && LATITUDE="$(settings ".latitude")"
    -	[[ -z ${LONGITUDE} ]] && LONGITUDE="$(settings ".longitude")"
     
    -	LATITUDE="$(convertLatLong "${LATITUDE}" "latitude")"		|| return 2
    -	LONGITUDE="$(convertLatLong "${LONGITUDE}" "longitude")"	|| return 2
    -
    -	echo "Daytime start    Nighttime start   Angle"
    -	local X="$(sunwait list angle "0" "${LATITUDE}" "${LONGITUDE}")"
    -	# Replace comma by several spaces so the output lines up.
    -	echo "${X/,/           }               0"
    -	X="$(sunwait list angle "${ANGLE}" "${LATITUDE}" "${LONGITUDE}")"
    -	echo "${X/,/           }              ${ANGLE}"
    +	[[ -z ${ANGLE} ]] && ANGLE="$( settings ".angle" )"
    +	[[ -z ${LATITUDE} ]] && LATITUDE="$( settings ".latitude" )"
    +	[[ -z ${LONGITUDE} ]] && LONGITUDE="$( settings ".longitude" )"
    +
    +	LATITUDE="$( convertLatLong "${LATITUDE}" "latitude" )"		|| return 2
    +	LONGITUDE="$( convertLatLong "${LONGITUDE}" "longitude" )"	|| return 2
    +
    +	local FORMAT="%-15s  %-17s  %-7s  %-10s  %-10s\n"
    +	# shellcheck disable=SC2059
    +	printf "${FORMAT}" "Daytime start" "Nighttime start" "Angle" "Latitude" "Longitude"
    +	local STARTS=()
    +	# sunwait output:  day_start, night_start
    +	# Need to get rid of the comma.
    +	if [[ ${DO_ZERO} == "true" ]]; then
    +		read -r -a STARTS <<< "$( sunwait list angle "0" "${LATITUDE}" "${LONGITUDE}" )"
    +		# shellcheck disable=SC2059
    +		printf "${FORMAT}" "${STARTS[0]/,/}" "${STARTS[1]}" "0" "${LATITUDE}" "${LONGITUDE}"
    +	fi
    +	read -r -a STARTS <<< "$( sunwait list angle "${ANGLE}" "${LATITUDE}" "${LONGITUDE}" )"
    +	# shellcheck disable=SC2059
    +	printf "${FORMAT}" "${STARTS[0]/,/}" "${STARTS[1]}" "${ANGLE}" "${LATITUDE}" "${LONGITUDE}"
     }
     
     
    @@ -248,47 +631,14 @@ function get_sunrise_sunset()
     # Return which Allsky Websites exist - local, remote, both, none
     function whatWebsites()
     {
    -	#shellcheck disable=SC2086 source-path=.
    +	#shellcheck source-path=.
     	source "${ALLSKY_HOME}/variables.sh"	|| return 1
     
     	local HAS_LOCAL="false"
     	local HAS_REMOTE="false"
     
    -	# Determine local Website - this is easy.
    -	[[ -f ${ALLSKY_WEBSITE_CONFIGURATION_FILE} ]] && HAS_LOCAL="true"
    -
    -	# Determine remote Website - this is more involved.
    -	# Not only must the file exist, but there also has to be a way to upload to it.
    -	if [[ -f ${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE} ]]; then
    -		local PROTOCOL="$(get_variable "PROTOCOL" "${ALLSKY_CONFIG}/ftp-settings.sh")"
    -		PROTOCOL=${PROTOCOL,,}
    -		if [[ -n ${PROTOCOL} && ${PROTOCOL} != "local" ]]; then
    -			local X
    -			case "${PROTOCOL}" in
    -				"" | local)
    -					;;
    -
    -				ftp | ftps | sftp | scp)		# These require R
    -					X="$(get_variable "REMOTE_HOST" "${ALLSKY_CONFIG}/ftp-settings.sh")" 
    -					[[ -n ${X} ]] && HAS_REMOTE="true"
    -					;;
    -
    -				s3)
    -					X="$(get_variable "AWS_CLI_DIR" "${ALLSKY_CONFIG}/ftp-settings.sh")" 
    -					[[ -n ${X} ]] && HAS_REMOTE="true"
    -					;;
    -
    -				gcs)
    -					X="$(get_variable "GCS_BUCKET" "${ALLSKY_CONFIG}/ftp-settings.sh")" 
    -					[[ -n ${X} ]] && HAS_REMOTE="true"
    -					;;
    -
    -				*)
    -					echo "ERROR: Unknown PROTOCOL: '${PROTOCOL}'" >&2
    -					;;
    -			esac
    -		fi
    -	fi
    +	[[ "$( settings ".uselocalwebsite" )" == "true" ]] && HAS_LOCAL="true"
    +	[[ "$( settings ".useremotewebsite" )" == "true" ]] && HAS_REMOTE="true"
     
     	if [[ ${HAS_LOCAL} == "true" ]]; then
     		if [[ ${HAS_REMOTE} == "true" ]]; then
    @@ -321,13 +671,13 @@ function checkAndGetNewerFile()
     	local GIT_FILE="${GITHUB_RAW_ROOT}/allsky/${BRANCH}/${2}"
     	local DOWNLOADED_FILE="${3}"
     	# Download the file and put in DOWNLOADED_FILE
    -	X="$(curl --show-error --silent "${GIT_FILE}")"
    +	X="$( curl --show-error --silent "${GIT_FILE}" )"
     	RET=$?
     	if [[ ${RET} -eq 0 && ${X} != "404: Not Found" ]]; then
     		# We really just check if the files are different.
     		echo "${X}" > "${DOWNLOADED_FILE}"
    -		DOWNLOADED_CHECKSUM="$(sum "${DOWNLOADED_FILE}")"
    -		MY_CHECKSUM="$(sum "${CURRENT_FILE}")"
    +		DOWNLOADED_CHECKSUM="$( sum "${DOWNLOADED_FILE}" )"
    +		MY_CHECKSUM="$( sum "${CURRENT_FILE}" )"
     		if [[ ${MY_CHECKSUM} == "${DOWNLOADED_CHECKSUM}" ]]; then
     			rm -f "${DOWNLOADED_FILE}"
     			return 0
    @@ -344,27 +694,35 @@ function checkAndGetNewerFile()
     
     
     #####
    -# Check for valid pixel values.
    -function checkPixelValue()	# variable name, variable value, width_or_height, resolution, min
    -{
    -	local VAR_NAME="${1}"
    -	local VAR_VALUE="${2}"
    -	local W_or_H="${3}"
    -	local MAX_RESOLUTION="${4}"
    -	local MIN=${5:-0}		# optional minimal pixel value
    +# Check for a single valid pixel value.
    +# Pixel sizes must be even.
    +function checkPixelValue()
    +{
    +	local NAME="${1}"
    +	local MAX_NAME="${2}"
    +	local VALUE="${3}"
    +	local MIN=${4}
    +	local MAX="${5}"
    +
    +	local MIN_MSG   MAX_MSG
     	if [[ ${MIN} == "any" ]]; then
     		MIN="-99999999"		# a number we'll never go below
    -		MSG="an"
    +		MIN_MSG="an integer"
     	else
    -		MIN=0
    -		MSG="a postive, even"
    +		MIN_MSG="an even integer from ${MIN}"
    +	fi
    +	if [[ ${MAX} == "any" ]]; then
    +		MAX="99999999"		# a number we'll never go above
    +		MAX_MSG=""
    +	else
    +		MAX_MSG=" up to the ${MAX_NAME} of ${MAX}"
     	fi
     
    -	if [[ ${VAR_VALUE} != +([-+0-9]) || ${VAR_VALUE} -le ${MIN} || $((VAR_VALUE % 2)) -eq 1 ]]; then
    -		echo "${VAR_NAME} (${VAR_VALUE}) must be ${MSG} integer up to ${MAX_RESOLUTION}."
    -		return 1
    -	elif [[ ${VAR_VALUE} -gt ${MAX_RESOLUTION} ]]; then
    -		echo "${VAR_NAME} (${VAR_VALUE}) is larger than the image ${W_or_H} (${MAX_RESOLUTION})."
    +	if [[ ${VALUE} != +([-+0-9]) ||
    +		  $((VALUE % 2)) -eq 1 ||
    +		  ${VALUE} -lt ${MIN} ||
    +		  ${VALUE} -gt ${MAX} ]]; then
    +		echo "${WSNs}${NAME}${WSNe} (${VALUE}) must be ${MIN_MSG}${MAX_MSG}." >&2
     		return 1
     	fi
     	return 0
    @@ -372,70 +730,87 @@ function checkPixelValue()	# variable name, variable value, width_or_height, res
     
     
     #####
    -# The crop rectangle needs to fit within the image, be an even number, and be greater than 0.
    -# x, y, offset_x, offset_y, max_resolution_x, max_resolution_y
    +# Make sure the specified width and height are valid.
    +# Assume each number has already been checked, e.g., it's not a string.
    +function checkWidthHeight()
    +{
    +	local NAME="${1}"
    +	local ITEM="${2}"
    +	local WIDTH="${3}"
    +	local HEIGHT="${4}"
    +	local SENSOR_WIDTH="${5}"
    +	local SENSOR_HEIGHT="${6}"
    +	local ERR=""
    +
    +	# Width and height must both be 0 or non-zero.
    +	if [[ (${WIDTH} -gt 0 && ${HEIGHT} -eq 0) || (${WIDTH} -eq 0 && ${HEIGHT} -gt 0) ]]; then
    +		ERR+="${WSNs}${NAME} Width${WSNe} (${WSVs}${WIDTH}${WSVe})"
    +		ERR+=" and ${WSNs}${NAME} Height${WSNe} (${WSVs}${HEIGHT}${WSVe})"
    +		ERR+=" must both be either 0 or non-zero.\n"
    +		ERR+="The ${ITEM} will NOT be resized since it would look unnatural.\n"
    +		ERR+="FIX: Either set both numbers to 0 to not resize,"
    +		ERR+=" or set both numbers to something greater than 0."
    +
    +	elif [[ ${WIDTH} -gt 0 && ${HEIGHT} -gt 0 &&
    +			${SENSOR_WIDTH} -eq ${WIDTH} && ${SENSOR_HEIGHT} -eq ${HEIGHT} ]]; then
    +		ERR+="Resizing a ${ITEM} to the same size as the sensor does nothing useful.\n"
    +		ERR+="FIX: Check ${WSNs}${NAME} Width${WSNe} (${WIDTH}) and"
    +		ERR+=" ${WSNs}${NAME} Height${WSNe} (${HEIGHT})"
    +		ERR+=" and set them to something other than the sensor size"
    +		ERR+=" (${WSVs}${SENSOR_WIDTH} x ${SENSOR_HEIGHT}${WSVe})."
    +	fi
    +
    +	[[ -z ${ERR} ]] && return 0
    +
    +	echo -e "${ERR}" >&2
    +	return 1
    +}
    +
    +
    +#####
    +# The crop rectangle needs to fit within the image and the numbers be even.
    +# TODO: should there be a maximum for any number (other than the image size)?
    +# Number of pixels to crop off top, right, bottom, left, plus max_resolution_x and max_resolution_y.
     function checkCropValues()
     {
    -	local X="${1}"
    -	local Y="${2}"
    -	local OFFSET_X="${3}"
    -	local OFFSET_Y="${4}"
    +	local CROP_TOP="${1}"
    +	local CROP_RIGHT="${2}"
    +	local CROP_BOTTOM="${3}"
    +	local CROP_LEFT="${4}"
     	local MAX_RESOLUTION_X="${5}"
     	local MAX_RESOLUTION_Y="${6}"
     
    -	local SENSOR_CENTER_X=$(( MAX_RESOLUTION_X / 2 ))
    -	local SENSOR_CENTER_Y=$(( MAX_RESOLUTION_Y / 2 ))
    -	local CROP_CENTER_ON_SENSOR_X=$(( SENSOR_CENTER_X + OFFSET_X ))
    -	# There appears to be a bug in "convert" with "-gravity Center"; the Y offset is applied
    -	# to the TOP of the image, not the CENTER.
    -	# The X offset is correctly applied to the image CENTER.
    -	# Should the division round up or down or truncate (current method)?
    -	local CROP_CENTER_ON_SENSOR_Y=$(( SENSOR_CENTER_Y + (OFFSET_Y / 2) ))
    -	local HALF_CROP_WIDTH=$(( X / 2 ))
    -	local HALF_CROP_HEIGHT=$(( Y / 2 ))
    -
    -	local CROP_TOP=$(( CROP_CENTER_ON_SENSOR_Y - HALF_CROP_HEIGHT ))
    -	local CROP_BOTTOM=$(( CROP_CENTER_ON_SENSOR_Y + HALF_CROP_HEIGHT ))
    -	local CROP_LEFT=$(( CROP_CENTER_ON_SENSOR_X - HALF_CROP_WIDTH ))
    -	local CROP_RIGHT=$(( CROP_CENTER_ON_SENSOR_X + HALF_CROP_WIDTH ))
    -
     	local ERR=""
    -	if [[ ${CROP_TOP} -lt 0 ]]; then
    -		ERR="${ERR}\nCROP rectangle goes off the top of the image by ${CROP_TOP#-} pixel(s)."
    +	if [[ ${CROP_TOP} -lt 0 || ${CROP_RIGHT} -lt 0 ||
    +			${CROP_BOTTOM} -lt 0 || ${CROP_LEFT} -lt 0 ]]; then
    +		ERR+="\nCrop numbers must all be positive."
     	fi
    -	if [[ ${CROP_BOTTOM} -gt ${MAX_RESOLUTION_Y} ]]; then
    -		ERR="${ERR}\nCROP rectangle goes off the bottom of the image: ${CROP_BOTTOM} is greater than image height (${MAX_RESOLUTION_Y})."
    +	if [[ $((CROP_TOP % 2)) -eq 1 || $((CROP_RIGHT % 2)) -eq 1 ||
    +			$((CROP_BOTTOM % 2)) -eq 1 || $((CROP_LEFT % 2)) -eq 1 ]]; then
    +		ERR+="\nCrop numbers must all be even."
     	fi
    -	if [[ ${CROP_LEFT} -lt 0 ]]; then
    -		ERR="${ERR}\nCROP rectangle goes off the left of the image: ${CROP_LEFT} is less than 0."
    +	if [[ ${CROP_TOP} -gt $((MAX_RESOLUTION_Y -2)) ]]; then
    +		ERR+="\nCropping on top (${CROP_TOP}) is larger than the image height (${MAX_RESOLUTION_Y})."
     	fi
    -	if [[ ${CROP_RIGHT} -gt ${MAX_RESOLUTION_X} ]]; then
    -		ERR="${ERR}\nCROP rectangle goes off the right of the image: ${CROP_RIGHT} is greater than image width (${MAX_RESOLUTION_X})."
    +	if [[ ${CROP_RIGHT} -gt $((MAX_RESOLUTION_X - 2)) ]]; then
    +		ERR+="\nCropping on right (${CROP_RIGHT}) is larger than the image width (${MAX_RESOLUTION_X})."
    +	fi
    +	if [[ ${CROP_BOTTOM} -gt $((MAX_RESOLUTION_Y - 2)) ]]; then
    +		ERR+="\nCropping on bottom (${CROP_BOTTOM}) is larger than the image height (${MAX_RESOLUTION_Y})."
    +	fi
    +	if [[ ${CROP_LEFT} -gt $((MAX_RESOLUTION_X - 2)) ]]; then
    +		ERR+="\nCropping on left (${CROP_LEFT}) is larger than the image width (${MAX_RESOLUTION_X})."
     	fi
     
     	if [[ -z ${ERR} ]]; then
     		return 0
     	else
    -		echo -e "${ERR}"
    +		echo -e "${ERR}" >&2
    +		echo "Crop settings: top: ${CROP_TOP}, right: ${CROP_RIGHT}, bottom: ${CROP_BOTTOM}, left: ${CROP_LEFT}" >&2
     		return 1
     	fi
     }
     
    -#####
    -# Get a shell variable's value.  The variable can have optional spaces and tabs before it.
    -# This function is useful when we can't "source" the file.
    -function get_variable() {
    -	local VARIABLE="${1}"
    -	local FILE="${2}"
    -	local LINE=""
    -	local SEARCH_STRING="^[ 	]*${VARIABLE}="
    -	if ! LINE="$( /bin/grep -E "${SEARCH_STRING}" "${FILE}" 2>/dev/null )" ; then
    -		return 1
    -	fi
    -
    -	echo "${LINE}" | sed -e "s/${SEARCH_STRING}//" -e 's/"//g'
    -	return 0
    -}
     
     #####
     # Simple way to get a setting that hides the details.
    @@ -445,7 +820,7 @@ function settings()
     {
     	local DO_NULL="false"
     	[[ ${1} == "--null" ]] && DO_NULL="true" && shift
    -	local M="${ME:-settings}"
    +	local M="${ME:-${FUNCNAME[0]}}"
     	local FIELD="${1}"
     	# Arrays can't begin with period but everything else should.
     	if [[ ${FIELD:0:1} != "." && ${FIELD: -2:2} != "[]" && ${FIELD:0:3} != "if " ]]; then
    @@ -454,6 +829,11 @@ function settings()
     	fi
     
     	local FILE="${2:-${SETTINGS_FILE}}"
    +	if [[ ! -f ${FILE} ]]; then
    +		echo "${M}: File '${FILE}' does not exist!  Cannot get '${FIELD}'." >&2
    +		return 2
    +	fi
    +
     	if j="$( jq -r "${FIELD}" "${FILE}" )" ; then
     		[[ ${j} == "null" && ${DO_NULL} == "false" ]] && j=""
     		echo "${j}"
    @@ -462,7 +842,7 @@ function settings()
     
     	echo "${M}: Unable to get json value for '${FIELD}' in '${FILE}." >&2
     	
    -	return 2
    +	return 3
     }
     
     
    @@ -476,13 +856,13 @@ function get_links()
     {
     	local FILE="$1"
     	if [[ -z ${FILE} ]]; then
    -		echo "get_links(): File not specified."
    +		echo "${FUNCNAME[0]}(): File not specified."
     		return 1
     	fi
     	local DIRNAME="$( dirname "${FILE}" )"
     
     	# shellcheck disable=SC2012
    -	local INODE="$( /bin/ls -l --inode "${FILE}" 2>/dev/null | cut -f1 -d' ' )"
    +	local INODE="$( stat --printf="%i" "${FILE}" 2>/dev/null )"
     	if [[ -z ${INODE} ]]; then
     		echo "File '${FILE}' not found."
     		return 2
    @@ -518,17 +898,29 @@ function get_links()
     function check_settings_link()
     {
     	local FULL_FILE FILE DIRNAME SETTINGS_LINK RET MSG F E CORRECT_NAME
    +	local CT="cameratype"
    +	local CM="cameramodel"
    +	if [[ ${1} == "--uppercase" ]]; then
    +		CT="cameraType"
    +		CM="cameraModel"
    +		shift
    +	fi
    +
     	FULL_FILE="${1}"
     	if [[ -z ${FULL_FILE} ]]; then
    -		echo "check_settings_link(): Settings file not specified."
    +		echo "${FUNCNAME[0]}(): Settings file not specified."
     		return "${EXIT_ERROR_STOP}"
     	fi
    +	if [[ ! -f ${FULL_FILE} ]]; then
    +		echo "${FUNCNAME[0]}(): File '${FULL_FILE}' not found."
    +		return 1
    +	fi
     	if [[ -z ${CAMERA_TYPE} ]]; then
    -		CAMERA_TYPE="$( settings .cameraType  "${FULL_FILE}" )"
    +		CAMERA_TYPE="$( settings ".${CT}"  "${FULL_FILE}" )"
     		[[ $? -ne 0 || -z ${CAMERA_TYPE} ]] && return "${EXIT_ERROR_STOP}"
     	fi
     	if [[ -z ${CAMERA_MODEL} ]]; then
    -		CAMERA_MODEL="$( settings .cameraModel  "${FULL_FILE}" )"
    +		CAMERA_MODEL="$( settings ".${CM}"  "${FULL_FILE}" )"
     		[[ $? -ne 0 || -z ${CAMERA_TYPE} ]] && return "${EXIT_ERROR_STOP}"
     	fi
     
    @@ -536,22 +928,22 @@ function check_settings_link()
     	FILE="$( basename "${FULL_FILE}" )"
     	F="${FILE%.*}"
     	E="${FILE##*.}"
    -	CORRECT_NAME="${F}_${CAMERA_TYPE}_${CAMERA_MODEL}.${E}"
    +	CORRECT_NAME="${F}_${CAMERA_TYPE}_${CAMERA_MODEL// /_}.${E}"
     	FULL_CORRECT_NAME="${DIRNAME}/${CORRECT_NAME}"
     	SETTINGS_LINK="$( get_links "${FULL_FILE}" )"
     	RET=$?
     	if [[ ${RET} -ne 0 ]]; then
     		MSG="The settings file '${FILE}' was not linked to '${CORRECT_NAME}'"
    -		[[ ${RET} -ne "${NO_LINK_}" ]] && MSG="${MSG}\nERROR: ${SETTINGS_LINK}."
    +		[[ ${RET} -ne "${NO_LINK_}" ]] && MSG+="\nERROR: ${SETTINGS_LINK}."
     		echo -e "${MSG}$( fix_settings_link "${FULL_FILE}" "${FULL_CORRECT_NAME}" )"
     		return 1
     	else
     		# Make sure it's linked to the correct file.
     		if [[ ${SETTINGS_LINK} != "${FULL_CORRECT_NAME}" ]]; then
     			MSG="The settings file (${FULL_FILE}) was linked to:"
    -			MSG="${MSG}\n    ${SETTINGS_LINK}"
    -			MSG="${MSG}\nbut should have been linked to:"
    -			MSG="${MSG}\n    ${FULL_CORRECT_NAME}"
    +			MSG+="\n    ${SETTINGS_LINK}"
    +			MSG+="\nbut should have been linked to:"
    +			MSG+="\n    ${FULL_CORRECT_NAME}"
     			echo -e "${MSG}$( fix_settings_link "${FULL_FILE}" "${FULL_CORRECT_NAME}" )"
     			return 1
     		fi
    @@ -582,29 +974,6 @@ function fix_settings_link()
     	return 0
     }
     
    -function update_json_file()		# field, new value, file
    -{
    -	local M="${ME:-update_json_file}"
    -	local FIELD="${1}"
    -	if [[ ${FIELD:0:1} != "." ]]; then
    -		echo "${M}: Field names must begin with period '.' (Field='${FIELD}')" >&2
    -		return 1
    -	fi
    -
    -	local NEW_VALUE="${2}"
    -	local FILE="${3:-${SETTINGS_FILE}}"
    -	local TEMP="/tmp/$$"
    -	# Have to use "cp" instead of "mv" to keep any hard link.
    -	if jq "${FIELD} = \"${NEW_VALUE}\"" "${FILE}" > "${TEMP}" ; then
    -		cp "${TEMP}" "${FILE}"
    -		rm "${TEMP}"
    -		return 0
    -	fi
    -
    -	echo "${M}: Unable to update json value of '${FIELD}' to '${NEW_VALUE}' in '${FILE}'." >&2
    -
    -	return 2
    -}
     
     ####
     # Only allow one of the specified process at a time.
    @@ -613,17 +982,18 @@ function one_instance()
     	local SLEEP_TIME="5s"
     	local MAX_CHECKS=3
     	local PID_FILE=""
    +	local PID=""
     	local ABORTED_FILE=""
     	local ABORTED_FIELDS=""
     	local ABORTED_MSG1=""
     	local ABORTED_MSG2=""
     	local CAUSED_BY=""
     
    -	OK="true"
    +	local OK="true"
     	local ERRORS=""
     	while [[ $# -gt 0 ]]; do
     		ARG="${1}"
    -		case "${ARG}" in
    +		case "${ARG,,}" in
     				--sleep)
     					SLEEP_TIME="${2}"
     					shift
    @@ -661,33 +1031,33 @@ function one_instance()
     					shift
     					;;
     				*)
    -					ERRORS="${ERRORS}\nUnknown argument: '${ARG}'."
    +					ERRORS+="\nUnknown argument: '${ARG}'."
     					OK="false"
     					;;
     		esac
     		shift
     	done
     	if [[ -z ${PID_FILE} ]]; then
    -		ERRORS="${ERRORS}\nPID_FILE not specified."
    +		ERRORS+="\nPID_FILE not specified."
     		OK="false"
     	fi
     	if [[ -z ${ABORTED_FILE} ]]; then
    -		ERRORS="${ERRORS}\nABORTED_FILE not specified."
    +		ERRORS+="\nABORTED_FILE not specified."
     		OK="false"
     	fi
     	if [[ -z ${ABORTED_FIELDS} ]]; then
    -		ERRORS="${ERRORS}\nABORTED_FIELDS not specified."
    +		ERRORS+="\nABORTED_FIELDS not specified."
     		OK="false"
     	fi
     	if [[ -z ${ABORTED_MSG1} ]]; then
    -		ERRORS="${ERRORS}\nABORTED_MSG1 not specified."
    +		ERRORS+="\nABORTED_MSG1 not specified."
     		OK="false"
     	fi
     	if [[ -z ${ABORTED_MSG2} ]]; then
    -		ERRORS="${ERRORS}\nABORTED_MSG2 not specified."
    +		ERRORS+="\nABORTED_MSG2 not specified."
     		OK="false"
     	fi
    -	# CAUSED_BY isn't required
    +	# CAUSED_BY and PID aren't required
     
     	if [[ ${OK} == "false" ]]; then
     		echo -e "${RED}${ME}: ERROR: ${ERRORS}.${NC}" >&2
    @@ -695,9 +1065,10 @@ function one_instance()
     	fi
     
     
    -	NUM_CHECKS=0
    +	[[ -z ${PID} ]] && PID="$$"
    +	local NUM_CHECKS=0
     	local INITIAL_PID
    -	while  : ;
    +	while  :
     	do
     		((NUM_CHECKS++))
     
    @@ -709,7 +1080,7 @@ function one_instance()
     
     		[[ ${NUM_CHECKS} -eq 1 ]] && INITIAL_PID="${CURRENT_PID}"
     
    -		# If the PID has changed since the first time we looked,
    +		# If the INITIAL_PID has changed since the first time we looked,
     		# that means another process grabbed the lock.
     		# Since there may be several processes waiting, exit.
     		if [[ ${NUM_CHECKS} -eq ${MAX_CHECKS} || ${CURRENT_PID} -ne ${INITIAL_PID} ]]; then
    @@ -718,9 +1089,10 @@ function one_instance()
     			if [[ ${CURRENT_PID} -ne ${INITIAL_PID} ]]; then
     				echo -n  "Another process (PID=${CURRENT_PID}) got the lock." >&2
     			else
    -				echo -n  "Made ${NUM_CHECKS} attempts at waiting. Process ${PID} still has lock." >&2
    +				echo -n  "Made ${NUM_CHECKS} attempts at waiting." >&2
    +				echo -n  " Process ${CURRENT_PID} still has lock." >&2
     			fi
    -			echo -n  " If this happens often, check your settings." >&2
    +			echo -n  " If this happens often, check your settings. PID=${PID}" >&2
     			echo -e  "${NC}" >&2
     			ps -fp "${CURRENT_PID}" >&2
     
    @@ -728,14 +1100,14 @@ function one_instance()
     			# If it's happening often let the user know.
     			[[ ! -d ${ALLSKY_ABORTS_DIR} ]] && mkdir "${ALLSKY_ABORTS_DIR}"
     			local AF="${ALLSKY_ABORTS_DIR}/${ABORTED_FILE}"
    -			echo -e "$(date)\t${ABORTED_FIELDS}" >> "${AF}"
    +			echo -e "$( date )\t${ABORTED_FIELDS}" >> "${AF}"
     			NUM=$( wc -l < "${AF}" )
     			if [[ ${NUM} -eq 10 ]]; then
     				MSG="${NUM} ${ABORTED_MSG2} have been aborted waiting for others to finish."
    -				[[ -n ${CAUSED_BY} ]] && MSG="${MSG}\n${CAUSED_BY}"
    +				[[ -n ${CAUSED_BY} ]] && MSG+="\n${CAUSED_BY}"
     				SEVERITY="warning"
    -				MSG="${MSG}\nOnce you have resolved the cause, reset the aborted counter:"
    -				MSG="${MSG}\n&nbsp; &nbsp; <code>rm -f '${AF}'</code>"
    +				MSG+="\nOnce you have resolved the cause, reset the aborted counter:"
    +				MSG+="\n&nbsp; &nbsp; <code>rm -f '${AF}'</code>"
     				"${ALLSKY_SCRIPTS}/addMessage.sh" "${SEVERITY}" "${MSG}"
     			fi
     
    @@ -745,7 +1117,6 @@ function one_instance()
     		fi
     	done
     
    -	[[ -z ${PID} ]] && PID="$$"
     	echo "${PID}" > "${PID_FILE}" || return 1
     
     	return 0
    @@ -759,6 +1130,7 @@ function make_thumbnail()
     	local SEC="${1}"
     	local INPUT_FILE="${2}"
     	local THUMBNAIL="${3}"
    +	local THUMBNAIL_SIZE_X="$( settings ".thumbnailsizex" )"
     	ffmpeg -loglevel error -ss "00:00:${SEC}" -i "${INPUT_FILE}" \
     		-filter:v scale="${THUMBNAIL_SIZE_X:-100}:-1" -frames:v 1 "${THUMBNAIL}"
     }
    @@ -782,47 +1154,118 @@ function reboot_needed()
     	fi
     }
     
    +
     ####
    -# Read json on stdin and output each field and value separated by a tab.
    -function convert_json_to_tabs()
    -{
    -	# Possible input formats, all with and without trailing "," and
    -	# with or without leading spaces or tabs.
    -	#   "field" : "value"
    -	#   "field" : number
    -	#   "field": "value"
    -	#   "field": number
    -	#   "field":"value"
    -	#   "field":number
    -	# Want to output two fields (field name and value), separated by tabs.
    -	# First get rid of the brackets,
    -	# then the optional leading spaces and tabs,
    -	# then everything between the field and and its value,
    -	# then ending " and/or comma.
    +# Upload to the appropriate Websites and/or servers.
    +# Everything is put relative to the root directory.
    +#
    +# --local-web: copy to local website
    +# --remote-web: upload to remote website
    +# --remote-server: upload to remote server
    +function upload_all()
    +{
    +	local ARGS=""
    +	local LOCAL_WEB=""
    +	local REMOTE_WEB=""
    +	local REMOTE_SERVER=""
    +	local SILENT=""
    +	local NUM=0
    +	while [[ ${1:0:2} == "--" ]]
    +	do
    +		ARG="${1,,}"
    +		if [[ ${ARG} == "--local-web" ]]; then
    +			LOCAL_WEB="${ARG}"
    +			(( NUM++ ))
    +		elif [[ ${ARG} == "--remote-web" ]]; then
    +			REMOTE_WEB="${ARG}"
    +			(( NUM++ ))
    +		elif [[ ${ARG} == "--remote-server" ]]; then
    +			REMOTE_SERVER="${ARG}"
    +			(( NUM++ ))
    +		elif [[ ${ARG} == "--silent" ]]; then
    +			SILENT="${ARG}"
    +		else
    +			ARGS+=" ${ARG}"
    +		fi
    +		shift
    +	done
     
    -	local JSON_FILE="${1}"
    -	if [[ ! -f ${JSON_FILE} ]]; then
    -		echo -e "${RED}convert_json_to_tabs(): ERROR: json file '${JSON_FILE}' not found.${NC}" >&2
    -		return 1
    +	# If no locations specified, try them all.
    +	if [[ ${NUM} -eq 0 ]]; then
    +		LOCAL_WEB="--local-web"
    +		REMOTE_WEB="--remote-web"
    +		REMOTE_SERVER="--remote-server"
     	fi
     
    -	sed -e '/^{/d' -e '/^}/d' \
    -		-e 's/^[\t ]*"//' \
    -		-e 's/"[\t :]*[ "]/\t/' \
    -		-e 's/",$//' -e 's/"$//' -e 's/,$//' \
    -			"${JSON_FILE}"
    +	local UPLOAD_FILE="${1}"
    +	local SUBDIR="${2}"
    +	local DESTINATION_NAME="${3}"
    +	local FILE_TYPE="${4}"		# optional
    +	local RET=0
    +	local ROOT  REMOTE_DIR
    +
    +	if [[ -n ${LOCAL_WEB} && "$( settings ".uselocalwebsite" )" == "true" ]]; then
    +		#shellcheck disable=SC2086
    +		"${ALLSKY_SCRIPTS}/upload.sh" ${SILENT} ${ARGS} "${LOCAL_WEB}" \
    +			"${UPLOAD_FILE}" "${SUBDIR}" "${DESTINATION_NAME}"
    +		((RET+=$?))
    +	fi
    +
    +	if [[ -n ${REMOTE_WEB} && "$( settings ".useremotewebsite" )" == "true" ]]; then
    +		ROOT="$( settings ".remotewebsiteimagedir" )"
    +		if [[ -z ${ROOT} ]]; then
    +			REMOTE_DIR="${SUBDIR}"
    +		else
    +			REMOTE_DIR="${ROOT}/${SUBDIR}"
    +		fi
    +		#shellcheck disable=SC2086
    +		"${ALLSKY_SCRIPTS}/upload.sh" ${SILENT} ${ARGS} "${REMOTE_WEB}" \
    +			"${UPLOAD_FILE}" "${REMOTE_DIR}" "${DESTINATION_NAME}" "${FILE_TYPE}-website"
    +		((RET+=$?))
    +	fi
    +
    +	if [[ -n ${REMOTE_SERVER} && "$( settings ".useremoteserver" )" == "true" ]]; then
    +		ROOT="$( settings ".remoteserverimagedir" )"
    +		if [[ -z ${ROOT} ]]; then
    +			REMOTE_DIR="${SUBDIR}"
    +		else
    +			REMOTE_DIR="${ROOT}/${SUBDIR}"
    +		fi
    +		#shellcheck disable=SC2086
    +		"${ALLSKY_SCRIPTS}/upload.sh" ${SILENT} ${ARGS} "${REMOTE_SERVER}" \
    +			"${UPLOAD_FILE}" "${REMOTE_DIR}" "${DESTINATION_NAME}" "${FILE_TYPE}-server"
    +		((RET+=$?))
    +	fi
    +
    +	return "${RET}"
     }
     
     
     # Indent all lines.
     function indent()
     {
    -	echo -e "${1}" | sed 's/^/\t/'
    +	local INDENT
    +	if [[ ${1} == "--spaces" ]]; then
    +		INDENT="    "
    +		shift
    +	elif [[ ${1} == "--html" ]]; then
    +		INDENT="&nbsp;&nbsp;&nbsp;&nbsp;"
    +		shift
    +	else
    +		INDENT="	"	# tab
    +	fi
    +	echo -e "${1}" | sed "s/^/${INDENT}/"
     }
     
    +
     # Python virtual environment
     PYTHON_VENV_ACTIVATED="false"
    -activate_python_venv() {
    +function activate_python_venv()
    +{
    +
    +# TODO: will need to change when the OS after bookworm is released
    +# If our next release is out, it won't support buster so may be check  != bullseye  ?
    +
     	if [[ ${PI_OS} == "bookworm" ]]; then
     		#shellcheck disable=SC1090,SC1091
     		source "${ALLSKY_PYTHON_VENV}/bin/activate" || exit 1
    @@ -832,6 +1275,93 @@ activate_python_venv() {
     	return 1
     }
     
    -deactivate_python_venv() {
    +function deactivate_python_venv()
    +{
     	[[ ${PYTHON_VENV_ACTIVATED} == "true" ]] && deactivate
     }
    +
    +
    +# Determine if the specified value is a number.
    +function is_number()
    +{
    +	local VALUE="${1}"
    +	[[ -z ${VALUE} ]] && return 1
    +	shopt -s extglob
    +	local NON_NUMERIC="${VALUE/?([-+])*([0-9])?(.)*([0-9])/}"
    +	if [[ -z ${NON_NUMERIC} ]]; then
    +		# Nothing but +, -, 0-9, or .
    +		return 0
    +	else
    +		# Has non-numeric character
    +		return 1
    +	fi
    +}
    +
    +
    +####
    +# Set the Allsky status along with a timestamp.
    +function set_allsky_status()
    +{
    +	local STATUS="${1}"		# can be ""
    +
    +	local S=".status = \"${STATUS}\""
    +	local T=".timestamp = \"$( date +'%Y-%m-%d %H:%M:%S' )\""
    +	if which jq >/dev/null ; then
    +		echo "{ }" | jq --indent 4 "${S} | ${T}" > "${ALLSKY_STATUS}"
    +	else
    +		echo "{ \"status\" : \"${S}\", \"timestamp\" : \"${T}\" }" > "${ALLSKY_STATUS}"
    +	fi
    +}
    +function get_allsky_status()
    +{
    +	settings ".status" "${ALLSKY_STATUS}" 2> /dev/null
    +}
    +function get_allsky_status_timestamp()
    +{
    +	settings ".timestamp" "${ALLSKY_STATUS}" 2> /dev/null
    +}
    +
    +
    +####
    +# Get the RPi camera model given its sensor name.
    +function get_model_from_sensor()
    +{
    +	local SENSOR="${1}"
    +
    +	gawk --field-separator '\t' -v sensor="${SENSOR}" '
    +		BEGIN {
    +			if (sensor == "") {
    +				printf("ERROR: No sensor specified.\n");
    +				ok = "false";
    +				exit(1);
    +			}
    +			model = "";
    +			ok = "true";
    +		}
    +		{
    +			if ($1 == "camera") {
    +				module = $2;
    +				module_len = $3;
    +				if ((module_len == 0 && module == sensor) ||
    +					(module == substr(sensor, 0, module_len))) {
    +
    +					model = $4;
    +					exit(0);
    +				}
    +			}
    +				
    +		}
    +		END {
    +			if (ok == "false") {
    +				exit(1);
    +			}
    +
    +			if (model != "") {
    +				print model;
    +				exit(0);
    +			} else {
    +				printf("unknown_sensor_%s\n", sensor);
    +				exit(1);
    +			}
    +		} ' "${RPi_SUPPORTED_CAMERAS}"
    +}
    diff --git a/scripts/generateForDay.sh b/scripts/generateForDay.sh
    index 4aff29e0b..0e4f4f948 100755
    --- a/scripts/generateForDay.sh
    +++ b/scripts/generateForDay.sh
    @@ -3,15 +3,13 @@
     # This script allows users to manually generate or upload keograms, startrails, and timelapses.
     
     # Allow this script to be executed manually, which requires several variables to be set.
    -[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$(realpath "$(dirname "${BASH_ARGV0}")/..")"
    -ME="$(basename "${BASH_ARGV0}")"
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
    +ME="$( basename "${BASH_ARGV0}" )"
     
    -#shellcheck disable=SC2086 source-path=.
    -source "${ALLSKY_HOME}/variables.sh"	|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086 source-path=scripts
    -source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/config.sh"		|| exit ${ALLSKY_ERROR_STOP}
    +#shellcheck source-path=.
    +source "${ALLSKY_HOME}/variables.sh"		|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${EXIT_ERROR_STOP}"
     
     DO_HELP="false"
     DEBUG_ARG=""
    @@ -27,10 +25,11 @@ DO_STARTRAILS="false"
     DO_TIMELAPSE="false"
     THUMBNAIL_ONLY="false"
     THUMBNAIL_ONLY_ARG=""
    +IMAGES_FILE=""
     
     while [[ $# -gt 0 ]]; do
     	ARG="${1}"
    -	case "${ARG}" in
    +	case "${ARG,,}" in
     			--help)
     				DO_HELP="true"
     				;;
    @@ -56,6 +55,10 @@ while [[ $# -gt 0 ]]; do
     				# On uploads, we should let upload.sh output messages since it has more details.
     				UPLOAD_SILENT=""
     				;;
    +			--images)
    +				IMAGES_FILE="${2}"
    +				shift
    +				;;
     			-k | --keogram)
     				DO_KEOGRAM="true"
     				((GOT++))
    @@ -82,40 +85,84 @@ done
     
     usage_and_exit()
     {
    -	retcode=${1}
    +	local RET=${1}
     	echo
    -	[[ ${retcode} -ne 0 ]] && echo -en "${RED}"
    +	[[ ${RET} -ne 0 ]] && echo -en "${RED}"
     	echo "Usage: ${ME} [--help] [--silent] [--debug] [--nice n] [--upload] \\"
    -	echo "    [--thumbnail-only] [--keogram] [--startrails] [--timelapse] DATE"
    -	[[ ${retcode} -ne 0 ]] && echo -en "${NC}"
    +	echo "    [--thumbnail-only] [--keogram] [--startrails] [--timelapse] \\"
    +	echo "    {--images file | <INPUT_DIR>}"
    +	[[ ${RET} -ne 0 ]] && echo -en "${NC}"
     	echo "    where:"
     	echo "      '--help' displays this message and exits."
     	echo "      '--debug' runs upload.sh in debug mode."
     	echo "      '--nice' runs with nice level n."
     	echo "      '--upload' uploads previously-created files instead of creating them."
     	echo "      '--thumbnail-only' creates or uploads video thumbnails only."
    -	echo "      'DATE' is the day in '${ALLSKY_IMAGES}' to process."
    +	echo "      'INPUT_DIR' is the day in '${ALLSKY_IMAGES}' to process."
     	echo "      '--keogram' will ${MSG1} a keogram."
     	echo "      '--startrails' will ${MSG1} a startrail."
     	echo "      '--timelapse' will ${MSG1} a timelapse."
     	echo "    If you don't specify --keogram, --startrails, or --timelapse, all three will be ${MSG2}."
    -	# shellcheck disable=SC2086
    -	exit ${retcode}
    +	echo
    +	echo "The list of images is determined in one of two ways:"
    +	echo "1. Looking in '<INPUT_DIR>' for files with an extension of '${EXTENSION}'."
    +	echo "   If <INPUT_DIR> is NOT a full path name it is assumed to be in '${ALLSKY_IMAGES}',"
    +	echo "   which allows using images on a USB stick, for example."
    +	echo "   The output file(s) are stored in <INPUT_DIR>."
    +	echo
    +	echo "2. Specifying '--images file' uses the images listed in 'file'; <INPUT_DIR> is not used."
    +	echo "   The output file is stored in the same directory as the first image."
    +	exit "${RET}"
     }
     
     [[ ${DO_HELP} == "true" ]] && usage_and_exit 0
    -[[ $# -eq 0 ]] && usage_and_exit 1
     
    -if [[ ${TYPE} == "UPLOAD" ]]; then
    -	#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -	source "${ALLSKY_CONFIG}/ftp-settings.sh" || exit ${ALLSKY_ERROR_STOP}
    +if [[ -n ${IMAGES_FILE} ]]; then
    +	# If IMAGES_FILE is specified there should be no other arguments.
    +	[[ $# -ne 0 ]] && usage_and_exit 1
    +elif [[ $# -eq 0 || $# -gt 1 ]]; then
    +	usage_and_exit 2
     fi
     
    -DATE="${1}"
    -OUTPUT_DIR="${ALLSKY_IMAGES}/${DATE}"
    -if [[ ! -d ${OUTPUT_DIR} ]]; then
    -	echo -e "${RED}${ME}: ERROR: '${OUTPUT_DIR}' not found!${NC}"
    -	exit 2
    +if [[ -n ${IMAGES_FILE} ]]; then
    +	if [[ ! -s ${IMAGES_FILE} ]]; then
    +		echo -e "${RED}*** ${ME} ERROR: '${IMAGES_FILE}' does not exist or is empty!${NC}"
    +		exit 3
    +	fi
    +	INPUT_DIR=""		# Not used
    +
    +	# Use the directory the images are in.  Only look at the first one.
    +	I="$( head -1 "${IMAGES_FILE}" )"
    +	OUTPUT_DIR="$( dirname "${I}" )"
    +
    +	# In case the filename doesn't include a path, put in a default location.
    +	if [[ ${OUTPUT_DIR} == "." ]]; then
    +		OUTPUT_DIR="${ALLSKY_TMP}"
    +		echo -en "${ME}: ${YELLOW}"
    +		echo "Can't determine where to put files so putting in '${OUTPUT_DIR}'."
    +		echo -e "${NC}"
    +	fi
    +
    +	# Use the basename of the directory.
    +	DATE="$( basename "${OUTPUT_DIR}" )"
    +
    +else
    +	INPUT_DIR="${1}"
    +
    +	# If not a full pathname, ${DIRNAME} will be "." so look in ${ALLSKY_IMAGES}.
    +	DIRNAME="$( dirname "${INPUT_DIR}" )"
    +	if [[ ${DIRNAME} == "." ]]; then
    +		DATE="${INPUT_DIR}"
    +		INPUT_DIR="${ALLSKY_IMAGES}/${INPUT_DIR}"	# Need full pathname for links.
    +	else
    +		DATE="$( basename "${INPUT_DIR}" )"
    +	fi
    +	if [[ ! -d ${INPUT_DIR} ]]; then
    +		echo -e "${RED}*** ${ME} ERROR: '${INPUT_DIR}' does not exist!${NC}"
    +		exit 4
    +	fi
    +
    +	OUTPUT_DIR="${INPUT_DIR}"	# Put output file(s) in same location as input files.
     fi
     
     if [[ ${GOT} -eq 0 ]]; then
    @@ -147,32 +194,51 @@ if [[ ${TYPE} == "GENERATE" ]]; then
     	}
     
     else
    -	upload()
    -	{
    -		FILE_TYPE="${1}"
    -		UPLOAD_FILE="${2}"
    -		DIRECTORY="${3}"
    -		DESTINATION_NAME="${4}"
    -		OVERRIDE_DESTINATION_NAME="${5}"	# optional
    -		WEB_DIRECTORY="${6}"				# optional
    -		if [[ -f ${UPLOAD_FILE} ]]; then
    -			# If the user specified a different name for the destination file, use it.
    -			if [[ ${OVERRIDE_DESTINATION_NAME} != "" ]]; then
    -				DESTINATION_NAME="${OVERRIDE_DESTINATION_NAME}"
    -			fi
    -			[[ ${SILENT} == "false" ]] && echo "===== Uploading '${UPLOAD_FILE}'"
    -			# shellcheck disable=SC2086
    -			"${ALLSKY_SCRIPTS}/upload.sh" ${UPLOAD_SILENT} ${DEBUG_ARG} \
    -				"${UPLOAD_FILE}" "${DIRECTORY}" "${DESTINATION_NAME}" \
    -				"${FILE_TYPE}" "${WEB_DIRECTORY}"
    -			return $?
    -		else
    -			echo -en "${YELLOW}"
    -			echo -n "WARNING: '${UPLOAD_FILE}' not found; skipping."
    -			echo -e "${NC}"
    -			return 1
    +	L_WEB_USE="$( settings ".uselocalwebsite" )"
    +	R_WEB_USE="$( settings ".useremotewebsite" )"
    +	R_SERVER_USE="$( settings ".useremoteserver" )"
    +	if [[ ${L_WEB_USE} == "false" &&
    +		  ${R_WEB_USE} == "false" &&
    +		  ${R_SERVER_USE} == "false" ]]; then
    +		echo -e "${RED}*** ${ME} ERROR: '--upload' specified but nowhere to upload!${NC}"
    +		exit 5
    +	fi
    +
    +	# Local Websites don't have directory or file name choices.
    +
    +	if [[ ${R_WEB_USE} == "true" ]]; then
    +		R_WEB_DEST_DIR="$( settings ".remotewebsiteimagedir" )"
    +		if [[ -n ${R_WEB_DEST_DIR} && ${R_WEB_DEST_DIR: -1:1} != "" ]]; then
    +			R_WEB_DEST_DIR="${R_WEB_DEST_DIR}/"
     		fi
    -	}
    +
    +		if [[ ${DO_KEOGRAM} == "true" ]]; then
    +			R_WEB_KEOGRAM_NAME="$( settings ".remotewebsitekeogramdestinationname" )"
    +		fi
    +		if [[ ${DO_STARTRAILS} == "true" ]]; then
    +			R_WEB_STARTRAILS_NAME="$( settings ".remotewebsitestartrailsdestinationname" )"
    +		fi
    +		if [[ ${DO_TIMELAPSE} == "true" ]]; then
    +			R_WEB_VIDEO_NAME="$( settings ".remotewebsitevideodestinationname" )"
    +		fi
    +	fi
    +
    +	if [[ ${R_SERVER_USE} == "true" ]]; then
    +		R_SERVER_DEST_DIR="$( settings ".remoteserverimagedir" )"
    +		if [[ -n ${R_SERVER_DEST_DIR} && ${R_SERVER_DEST_DIR: -1:1} != "" ]]; then
    +			R_SERVER_DEST_DIR="${R_SERVER_DEST_DIR}/"
    +		fi
    +
    +		if [[ ${DO_KEOGRAM} == "true" ]]; then
    +			R_SERVER_KEOGRAM_NAME="$( settings ".remoteserverkeogramdestinationname" )"
    +		fi
    +		if [[ ${DO_STARTRAILS} == "true" ]]; then
    +			R_SERVER_STARTRAILS_NAME="$( settings ".remoteserverstartrailsdestinationname" )"
    +		fi
    +		if [[ ${DO_TIMELAPSE} == "true" ]]; then
    +			R_SERVER_VIDEO_NAME="$( settings ".remoteservervideodestinationname" )"
    +		fi
    +	fi
     fi
     
     EXIT_CODE=0
    @@ -184,7 +250,7 @@ if [[ ${DO_KEOGRAM} == "true" || ${DO_STARTRAILS} == "true" ]]; then
     	# a non-empty string (eg. IMGSIZE="1280x960") will be produced and later
     	# parts of this script so startrail and keogram generation can use it
     	# to reject incorrectly-sized images.
    -	IMGSIZE=$(settings 'if .width != null and .height != null and .width != "0" and .height != "0" and .width != 0 and .height != 0 then "\(.width)x\(.height)" else empty end')
    +	IMGSIZE=$( settings 'if .width != null and .height != null and .width != "0" and .height != "0" and .width != 0 and .height != 0 then "\(.width)x\(.height)" else empty end' )
     	if [[ ${IMGSIZE} != "" ]]; then
     		SIZE_FILTER="-s ${IMGSIZE//\"}"
     	else
    @@ -196,51 +262,157 @@ fi
     if [[ ${DO_KEOGRAM} == "true" ]]; then
     	KEOGRAM_FILE="keogram-${DATE}.${EXTENSION}"
     	UPLOAD_FILE="${OUTPUT_DIR}/keogram/${KEOGRAM_FILE}"
    +
     	if [[ ${TYPE} == "GENERATE" ]]; then
    -		if [[ -z "${NICE}" ]]; then
    +		if [[ -z ${NICE} ]]; then
     			N=""
     		else
     			N="--nice-level ${NICE}"
     		fi
    +		KEOGRAM_EXTRA_PARAMETERS="$( settings ".keogramextraparameters" )"
    +		MORE=""
    +		EXPAND="$( settings ".keogramexpand" )"
    +			[[ ${EXPAND} == "true" ]] && MORE="${MORE} --image-expand"
    +		NAME="$( settings ".keogramfontname" )"
    +			[[ ${NAME} != "" ]] && MORE="${MORE} --font-name ${NAME}"
    +		COLOR="$( settings ".keogramfontcolor" )"
    +			[[ ${COLOR} != "" ]] && MORE="${MORE} --font-color '${COLOR}'"
    +		SIZE="$( settings ".keogramfontsize" )"
    +			[[ ${SIZE} != "" ]] && MORE="${MORE} --font-size ${SIZE}"
    +		THICKNESS="$( settings ".keogramlinethickness" )"
    +			[[ ${THICKNESS} != "" ]] && MORE="${MORE} --font-type ${THICKNESS}"
     		CMD="'${ALLSKY_BIN}/keogram' ${N} ${SIZE_FILTER} -d '${OUTPUT_DIR}' \
    -			-e ${EXTENSION} -o '${UPLOAD_FILE}' ${KEOGRAM_EXTRA_PARAMETERS}"
    +			-e ${EXTENSION} -o '${UPLOAD_FILE}' ${MORE} ${KEOGRAM_EXTRA_PARAMETERS}"
     		generate "Keogram" "keogram" "${CMD}"
    +
    +		if [[ $? -gt 90 && (${DO_STARTRAILS} == "true" || ${DO_TIMELAPSE} == "true") ]]; then
    +			DO_STARTRAILS="false"
    +			DO_TIMELAPSE="false"
    +			# -gt 90 means either no files or unable to read initial file, and
    +			# keograms will have the same problem, so don't bother running.
    +			echo "Keogram creation unable to read files; will not run startrails or timelapse."
    +		fi
    +
     	else
    -		upload "Keogram" "${UPLOAD_FILE}" "${KEOGRAM_DIR}" "${KEOGRAM_FILE}" \
    -			 "${KEOGRAM_DESTINATION_NAME}" "${WEB_KEOGRAM_DIR}"
    +		if [[ ! -f ${UPLOAD_FILE} ]]; then
    +			echo -en "${YELLOW}"
    +			echo -n "WARNING: '${UPLOAD_FILE}' not found; skipping."
    +			echo -e "${NC}"
    +			((EXIT_CODE++))
    +		else
    +			DEST_DIR="keograms"
    +
    +			if [[ ${L_WEB_USE} == "true" ]]; then
    +				DEST_NAME="${KEOGRAM_FILE}"
    +				#shellcheck disable=SC2086
    +				"${ALLSKY_SCRIPTS}/upload.sh" ${UPLOAD_SILENT} ${DEBUG_ARG} "--local-web" \
    +					"${UPLOAD_FILE}" "${DEST_DIR}" "${DEST_NAME}"
    +				((EXIT_CODE+=$?))
    +			fi
    +			if [[ ${R_WEB_USE} == "true" ]]; then
    +				if [[ -n ${R_WEB_KEOGRAM_NAME} ]]; then
    +					DEST_NAME="${R_WEB_KEOGRAM_NAME}"
    +				else
    +					DEST_NAME="${KEOGRAM_FILE}"
    +				fi
    +
    +				#shellcheck disable=SC2086
    +				"${ALLSKY_SCRIPTS}/upload.sh" ${UPLOAD_SILENT} ${DEBUG_ARG} "--remote-web" \
    +					"${UPLOAD_FILE}" "${R_WEB_DEST_DIR}${DEST_DIR}" "${DEST_NAME}" "Keogram"
    +				((EXIT_CODE+=$?))
    +			fi
    +			if [[ ${R_SERVER_USE} == "true" ]]; then
    +				if [[ -n ${R_SERVER_KEOGRAM_NAME} ]]; then
    +					DEST_NAME="${R_SERVER_KEOGRAM_NAME}"
    +				else
    +					DEST_NAME="${KEOGRAM_FILE}"
    +				fi
    +
    +				#shellcheck disable=SC2086
    +				"${ALLSKY_SCRIPTS}/upload.sh" ${UPLOAD_SILENT} ${DEBUG_ARG} "--remote-server" \
    +					"${UPLOAD_FILE}" "${R_SERVER_DEST_DIR}${DEST_DIR}" "${DEST_NAME}" "Keogram"
    +				((EXIT_CODE+=$?))
    +			fi
    +		fi
     	fi
    -	[[ $? -ne 0 ]] && ((EXIT_CODE++))
     fi
     
     if [[ ${DO_STARTRAILS} == "true" ]]; then
     	STARTRAILS_FILE="startrails-${DATE}.${EXTENSION}"
     	UPLOAD_FILE="${OUTPUT_DIR}/startrails/${STARTRAILS_FILE}"
     	if [[ ${TYPE} == "GENERATE" ]]; then
    -		if [[ -z "${NICE}" ]]; then
    +		if [[ -z ${NICE} ]]; then
     			N=""
     		else
     			N="--nice ${NICE}"
     		fi
    +		BRIGHTNESS_THRESHOLD="$( settings ".startrailsbrightnessthreshold" )"
    +		STARTRAILS_EXTRA_PARAMETERS="$( settings ".startrailsextraparameters" )"
     		CMD="'${ALLSKY_BIN}/startrails' ${N} ${SIZE_FILTER} -d '${OUTPUT_DIR}' \
     			-e ${EXTENSION} -b ${BRIGHTNESS_THRESHOLD} -o '${UPLOAD_FILE}' \
     			${STARTRAILS_EXTRA_PARAMETERS}"
     		generate "Startrails, threshold=${BRIGHTNESS_THRESHOLD}" "startrails" "${CMD}"
    +
    +		if [[ $? -gt 90 && (${DO_KEOGRAM} == "true" || ${DO_TIMELAPSE} == "true") ]]; then
    +			DO_STARTRAILS="false"
    +			# -gt 90 means either no files or unable to read initial file, and
    +			# startrails will have the same problem, so don't bother running.
    +			echo "Startrails creation unable to read files; will not run keogram or timelapse."
    +		fi
    +
     	else
    -		upload "Startrails" "${UPLOAD_FILE}" "${STARTRAILS_DIR}" "${STARTRAILS_FILE}" \
    -			"${STARTRAILS_DESTINATION_NAME}" "${WEB_STARTRAILS_DIR}"
    +		if [[ ! -f ${UPLOAD_FILE} ]]; then
    +			echo -en "${YELLOW}"
    +			echo -n "WARNING: '${UPLOAD_FILE}' not found; skipping."
    +			echo -e "${NC}"
    +			((EXIT_CODE++))
    +		else
    +			DEST_DIR="startrails"
    +
    +			if [[ ${L_WEB_USE} == "true" ]]; then
    +				DEST_NAME="${STARTRAILS_FILE}"
    +				#shellcheck disable=SC2086
    +				"${ALLSKY_SCRIPTS}/upload.sh" ${UPLOAD_SILENT} ${DEBUG_ARG} "--local-web" \
    +					"${UPLOAD_FILE}" "${DEST_DIR}" "${DEST_NAME}"
    +				((EXIT_CODE+=$?))
    +			fi
    +			if [[ ${R_WEB_USE} == "true" ]]; then
    +				if [[ -n ${R_WEB_STARTRAILS_NAME} ]]; then
    +					DEST_NAME="${R_WEB_STARTRAILS_NAME}"
    +				else
    +					DEST_NAME="${STARTRAILS_FILE}"
    +				fi
    +
    +				#shellcheck disable=SC2086
    +				"${ALLSKY_SCRIPTS}/upload.sh" ${UPLOAD_SILENT} ${DEBUG_ARG} "--remote-web" \
    +					"${UPLOAD_FILE}" "${R_WEB_DEST_DIR}${DEST_DIR}" "${DEST_NAME}" "Startrails"
    +				((EXIT_CODE+=$?))
    +			fi
    +			if [[ ${R_SERVER_USE} == "true" ]]; then
    +				if [[ -n ${R_SERVER_STARTRAILS_NAME} ]]; then
    +					DEST_NAME="${R_SERVER_STARTRAILS_NAME}"
    +				else
    +					DEST_NAME="${STARTRAILS_FILE}"
    +				fi
    +
    +				#shellcheck disable=SC2086
    +				"${ALLSKY_SCRIPTS}/upload.sh" ${UPLOAD_SILENT} ${DEBUG_ARG} "--remote-server" \
    +					"${UPLOAD_FILE}" "${R_SERVER_DEST_DIR}${DEST_DIR}" "${DEST_NAME}" "Startrails"
    +				((EXIT_CODE+=$?))
    +			fi
    +		fi
     	fi
    -	[[ $? -ne 0 ]] && ((EXIT_CODE++))
     fi
     
     if [[ ${DO_TIMELAPSE} == "true" ]]; then
    -	VIDEOS_FILE="allsky-${DATE}.mp4"
    +	VIDEO_FILE="allsky-${DATE}.mp4"
     	# Need a different name for the file so it's not mistaken for a regular image in the WebUI.
     	THUMBNAIL_FILE="thumbnail-${DATE}.jpg"
     
    -	UPLOAD_THUMBNAIL_NAME="allsky-${DATE}.jpg"
     	UPLOAD_THUMBNAIL="${OUTPUT_DIR}/${THUMBNAIL_FILE}"
    -	UPLOAD_FILE="${OUTPUT_DIR}/${VIDEOS_FILE}"
    +	UPLOAD_FILE="${OUTPUT_DIR}/${VIDEO_FILE}"
     
    +	TIMELAPSE_UPLOAD_THUMBNAIL="$( settings ".timelapseuploadthumbnail" )"
     	if [[ ${TYPE} == "GENERATE" ]]; then
     		if [[ ${THUMBNAIL_ONLY} == "true" ]]; then
     			if [[ -f ${UPLOAD_FILE} ]]; then
    @@ -251,16 +423,21 @@ if [[ ${DO_TIMELAPSE} == "true" ]]; then
     				RET=1
     			fi
     		else
    -			if [[ -z "${NICE}" ]]; then
    +			if [[ -z ${NICE} ]]; then
     				N=""
     			else
     				N="nice -n ${NICE}"
     			fi
    -			CMD="${N} '${ALLSKY_SCRIPTS}/timelapse.sh' --output '${UPLOAD_FILE}' ${DATE}"
    +			if [[ -n ${IMAGES_FILE} ]]; then
    +				X="--images '${IMAGES_FILE}'"
    +			else
    +				X="--output '${UPLOAD_FILE}' '${INPUT_DIR}'"
    +			fi
    +			CMD="${N} '${ALLSKY_SCRIPTS}/timelapse.sh' ${DEBUG_ARG} ${X}"
     			generate "Timelapse" "" "${CMD}"	# it creates the necessary directory
     			RET=$?
     		fi
    -		if [[ ${RET} -eq 0 && ${TIMELAPSE_UPLOAD_THUMBNAIL} == "true" ]]; then
    +		if [[ ${RET} -eq 0 && ${TIMELAPSE_UPLOAD_THUMBNAIL} == "true" && -s ${UPLOAD_FILE} ]]; then
     			rm -f "${UPLOAD_THUMBNAIL}"
     			# Want the thumbnail to be near the start of the video, but not the first frame
     			# since that can be a lousy frame.
    @@ -273,25 +450,80 @@ if [[ ${DO_TIMELAPSE} == "true" ]]; then
     				echo -e "${RED}${ME}: ERROR: video thumbnail not created!${NC}"
     			fi
     		fi
    +
    +	elif [[ ! -f ${UPLOAD_FILE} ]]; then
    +		echo -en "${YELLOW}"
    +		echo -n "WARNING: '${UPLOAD_FILE}' not found; skipping."
    +		echo -e "${NC}"
    +		((EXIT_CODE++))
     	else
    -		if [[ ${THUMBNAIL_ONLY} == "true" ]]; then
    -			RET=0
    -		else
    -			upload "Timelapse" "${UPLOAD_FILE}" "${VIDEOS_DIR}" "${VIDEOS_FILE}" \
    -				"${VIDEOS_DESTINATION_NAME}" "${WEB_VIDEOS_DIR}"
    -			RET=$?
    +		DEST_DIR="videos"
    +
    +		if [[ ${L_WEB_USE} == "true" ]]; then
    +			DEST_NAME="${VIDEO_FILE}"		# no name choice for local Website
    +			D="${DEST_DIR}"
    +			if [[ ${THUMBNAIL_ONLY} != "true" ]]; then
    +				#shellcheck disable=SC2086
    +				"${ALLSKY_SCRIPTS}/upload.sh" ${UPLOAD_SILENT} ${DEBUG_ARG} "--local-web" \
    +					"${UPLOAD_FILE}" "${D}" "${DEST_NAME}"
    +				RET=$?
    +				((EXIT_CODE+=RET))
    +			else
    +				RET=0
    +			fi
    +			if [[ ${RET} -eq 0 && ${TIMELAPSE_UPLOAD_THUMBNAIL} == "true" && -f ${UPLOAD_THUMBNAIL} ]]; then
    +				#shellcheck disable=SC2086
    +				"${ALLSKY_SCRIPTS}/upload.sh" ${UPLOAD_SILENT} ${DEBUG_ARG} "--local-web" \
    +					"${UPLOAD_THUMBNAIL}" "${D}/thumbnails" "${DEST_NAME/mp4/jpg}"
    +			fi
    +		fi
    +		if [[ ${R_WEB_USE} == "true" ]]; then
    +			if [[ -n ${R_WEB_VIDEO_NAME} ]]; then
    +				DEST_NAME="${R_WEB_VIDEO_NAME}"
    +			else
    +				DEST_NAME="${VIDEO_FILE}"
    +			fi
    +
    +			D="${R_WEB_DEST_DIR}${DEST_DIR}"
    +			if [[ ${THUMBNAIL_ONLY} != "true" ]]; then
    +				#shellcheck disable=SC2086
    +				"${ALLSKY_SCRIPTS}/upload.sh" ${UPLOAD_SILENT} ${DEBUG_ARG} "--remote-web" \
    +					"${UPLOAD_FILE}" "${D}" "${DEST_NAME}" "Timelapse"
    +				RET=$?
    +				((EXIT_CODE+=RET))
    +			else
    +				RET=0
    +			fi
    +			if [[ ${RET} -eq 0 && ${TIMELAPSE_UPLOAD_THUMBNAIL} == "true" && -f ${UPLOAD_THUMBNAIL} ]]; then
    +				#shellcheck disable=SC2086
    +				"${ALLSKY_SCRIPTS}/upload.sh" ${UPLOAD_SILENT} ${DEBUG_ARG} "--remote-web" \
    +					"${UPLOAD_THUMBNAIL}" "${D}/thumbnails" "${DEST_NAME/mp4/jpg}" "TimelapseThumbnail"
    +			fi
     		fi
    -		if [[ ${RET} -eq 0 && ${TIMELAPSE_UPLOAD_THUMBNAIL} == "true" && -f ${UPLOAD_THUMBNAIL} ]]; then
    -			if [[ -n ${WEB_VIDEOS_DIR} ]]; then
    -				W="${WEB_VIDEOS_DIR}/thumbnails"
    +		if [[ ${R_SERVER_USE} == "true" ]]; then
    +			if [[ -n ${R_SERVER_VIDEO_NAME} ]]; then
    +				DEST_NAME="${R_SERVER_VIDEO_NAME}"
     			else
    -				W=""
    +				DEST_NAME="${VIDEO_FILE}"
    +			fi
    +
    +			D="${R_SERVER_DEST_DIR}${DEST_DIR}"
    +			if [[ ${THUMBNAIL_ONLY} != "true" ]]; then
    +				#shellcheck disable=SC2086
    +				"${ALLSKY_SCRIPTS}/upload.sh" ${UPLOAD_SILENT} ${DEBUG_ARG} "--remote-server" \
    +					"${UPLOAD_FILE}" "${D}" "${DEST_NAME}" "Timelapse"
    +				RET=$?
    +				((EXIT_CODE+=RET))
    +			else
    +				RET=0
    +			fi
    +			if [[ ${RET} -eq 0 && ${TIMELAPSE_UPLOAD_THUMBNAIL} == "true" && -f ${UPLOAD_THUMBNAIL} ]]; then
    +				#shellcheck disable=SC2086
    +				"${ALLSKY_SCRIPTS}/upload.sh" ${UPLOAD_SILENT} ${DEBUG_ARG} "--remote-server" \
    +					"${UPLOAD_THUMBNAIL}" "${D}/thumbnails" "${DEST_NAME/mp4/jpg}" "TimelapseThumbnail"
     			fi
    -			upload "TimelapseThumbnail" "${UPLOAD_THUMBNAIL}" "${VIDEOS_DIR}/thumbnails" \
    -				"${UPLOAD_THUMBNAIL_NAME}" "" "${W}"
     		fi
     	fi
    -	[[ ${RET} -ne 0 ]] && ((EXIT_CODE++))
     fi
     
     
    @@ -306,5 +538,4 @@ if [[ ${TYPE} == "GENERATE" && ${SILENT} == "false" && ${EXIT_CODE} -eq 0 ]]; th
     	echo "================"
     fi
     
    -#shellcheck disable=SC2086
    -exit ${EXIT_CODE}
    +exit "${EXIT_CODE}"
    diff --git a/scripts/generate_notification_images.sh b/scripts/generate_notification_images.sh
    index c6625279d..8b26f71ac 100755
    --- a/scripts/generate_notification_images.sh
    +++ b/scripts/generate_notification_images.sh
    @@ -6,11 +6,11 @@
     # This is quick - on a Pi 4 it takes about one second per image.
     
     # Allow this script to be executed manually, which requires several variables to be set.
    -[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$(realpath "$(dirname "${BASH_ARGV0}")/..")"
    -ME="$(basename "${BASH_ARGV0}")"
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
    +ME="$( basename "${BASH_ARGV0}" )"
     
    -#shellcheck disable=SC2086 source-path=.
    -source "${ALLSKY_HOME}/variables.sh"	|| exit ${ALLSKY_ERROR_STOP}
    +#shellcheck disable=SC1091 source-path=.
    +source "${ALLSKY_HOME}/variables.sh"	|| exit "${EXIT_ERROR_STOP}"
     
     readonly ALL_EXTS="jpg png"		# all the image filename extensions we support
     
    @@ -22,21 +22,23 @@ DEFAULT_IMAGE_SIZE="959x719"
     
     function usage_and_exit()
     {
    -	RET=${1}
    -	(
    +	local RET=${1}
    +	{
     		[[ ${RET} -ne 0 ]] && echo -en "${RED}"
     		echo -e "\nUsage: ${ME} [--help] [--directory dir] [--size XxY]"
    -		echo -e "    [type TextColor Font FontSize StrokeColor StrokeWidth BgColor BorderWidth BorderColor Extensions ImageSize 'Message']\n"
    +		echo -e "    [type TextColor Font FontSize StrokeColor StrokeWidth BgColor"
    +		echo -e "     BorderWidth BorderColor Extensions ImageSize Message]\n"
     		[[ ${RET} -ne 0 ]] && echo -en "${NC}"
    -		echo "When run with no arguments, all notification types are created with extensions: ${ALL_EXTS/ /, }."
    -		echo "Arguments:"
    -		echo "  '--help' displays this message and exits."
    -		echo "  '--directory dir' creates the file(s) in that directory, otherwise in \${PWD}."
    -		echo "  '--size XxY' creates images that are X by Y pixels.  Default: ${DEFAULT_IMAGE_SIZE} pixels."
    +		echo -n "When run with no arguments, all notification types are created with extensions:"
    +		echo    "  ${ALL_EXTS/ /, }."
    +		echo    "Arguments:"
    +		echo    "  '--help' displays this message and exits."
    +		echo    "  '--directory dir' creates the file(s) in that directory, otherwise in \${PWD}."
    +		echo -n "  '--size XxY' creates images that are X by Y pixels."
    +		echo    "  Default: ${DEFAULT_IMAGE_SIZE} pixels."
     		echo
    -	) >&2
    -	# shellcheck disable=SC2086
    -	exit ${RET}
    +	} >&2
    +	exit "${RET}"
     }
     
     # Check arguments
    @@ -46,7 +48,7 @@ DIRECTORY=""
     IMAGE_SIZE="${DEFAULT_IMAGE_SIZE}"
     while [[ $# -gt 0 ]]; do
     	ARG="${1}"
    -	case "${ARG}" in
    +	case "${ARG,,}" in
     		--help)
     			HELP="true"
     			;;
    @@ -83,8 +85,12 @@ done
     MAX_ARGS=12
     
     if [[ $# -ne 0 && $# -ne ${MAX_ARGS} ]]; then
    -	echo -e "${RED}${ME}: ERROR: Either specify ALL ${MAX_ARGS} arguments, or don't specify any.${NC}" >&2
    -	echo "You specified $# arguments." >&2
    +	{
    +		echo -e "${RED}"
    +		echo    "${ME}: ERROR: Either specify ALL ${MAX_ARGS} arguments, or don't specify any."
    +		echo -e "${NC}"
    +		echo "You specified $# arguments."
    +	} >&2
     	usage_and_exit 1
     fi
     
    @@ -108,7 +114,7 @@ function make_image()
     	TEXTCOLOR="${2:-"white"}"
     	FONT="${3:-"Helvetica-Bold"}"
     	FONT_SIZE="${4:-128}"
    -	INTERLINE_SPACING=$(echo "${FONT_SIZE} / 3" | bc)
    +	INTERLINE_SPACING=$( echo "${FONT_SIZE} / 3" | bc )
     	STROKE_COLOR="${5:-"black"}"
     	STROKE_WIDTH="${6:-2}"
     	BGCOLOR="${7:-"#404040"}"
    @@ -130,8 +136,11 @@ function make_image()
     		BORDER=""
     	fi
     
    -	[[ ${ON_TTY} -eq 1 ]] && echo "Creating '${BASENAME}' in ${PWD}."
    +	[[ ${ON_TTY} == "true" ]] && echo "Creating '${BASENAME}' in ${PWD}."
     	for EXT in ${EXTS} ; do
    +		# In case ${EXT} starts with a ".", remove it.
    +		EXT="${EXT/./}"
    +
     		# Make highest quality for jpg and highest loss-less compression for png.
     		# jpg files at 95% produce somewhat bad artifacts.  Even 100% produces some artifacts.
     
    @@ -155,17 +164,22 @@ function make_image()
     			-depth 8 \
     			-size "${IM_SIZE}" \
     			label:"${MSG}" \
    -			"${BASENAME}.${EXT}"
    +			"${BASENAME}.${EXT}" || echo "${ME}: Unable to create image for '${MSG}'" >&2
     	done
    +
    +	return 0
     }
     
    -which mogrify > /dev/null
    -if [[ $? -ne 0 ]]; then
    +if ! which mogrify > /dev/null ; then
     	# Testing for mogrify which seems like a much more distinctive executable
     	# name than "convert". I assume that if "mogrify" is in the path, then
     	# ImageMagick is installed and "convert" will run ImageMagick and not some
     	# other tool.
    -	echo -e "${RED}${ME}: ERROR: ImageMagick does not appear to be installed. Please install it.${NC}" >&2
    +	{
    +		echo -e "${RED}"
    +		echo -e "${ME}: ERROR: ImageMagick does not appear to be installed. Please install it."
    +		echo -e "${NC}"
    +	} >&2
     	exit 2
     fi
     
    @@ -182,23 +196,26 @@ fi
     # If the arguments were specified on the command line, use them instead of the list below.
     if [[ $# -eq ${MAX_ARGS} ]]; then
     	make_image "${@}"
    +	exit $?
    +fi
     
    -elif [[ $# -eq 0 ]]; then
    -#            #1                   #2         #3                 #4     #5        #6      #7         #8      #9        #10         #11       #12
    -#            Basename             Text       Font               Font   Stroke    Stroke  Background Border  Border    Extensions  Image     Message
    -#                                 Color      Name               Size   Color     Width   Color      Width   Color                 Size
    -#            ""                   "white"    "Helvetica-Bold"   128    "black"   2       "#404040"  0       "white"   ${ALL_EXTS} "959x719" ""
    -#            +--------------------+----------+------------------+------+---------+-------+----------+-------+---------+-----------+---------+--------------------------------------
    -  make_image NotRunning           "red"      ""                 ""     ""        ""      ""         ""      ""        ""          ""        "Allsky\nis not\nrunning"
    -  make_image DarkFrames           "green"    ""                 ""     "white"    1      "black"    ""      ""        ""          ""        "Camera\nis taking\ndark frames"
    -  make_image StartingUp           "lime"     ""                 150    ""        ""      ""         10      "lime"    ""          ""        "Allsky\nis starting\nup"
    -  make_image Restarting           "lime"     ""                 ""     ""        ""      ""          7      "lime"    ""          ""        "Allsky\nis restarting"
    -  make_image CameraOffDuringDay   "#ffff4a"  ""                 ""     ""        ""      "gray"      5      "yellow"  ""          ""        "Camera\nis off\nduring the day"
    -  make_image Error                "red"      ""                 80     ""        ""      ""         10      "red"     ""          ""        "ERROR\n\nSee\n/var/log/allsky.log\nfor details"
    -
    -  make_image ConfigurationNeeded  "yellow"   ""                 80     ""        ""      ""         ""      ""        ""          ""        "***\nUse the WebUI\n'Allsky Settings'\nlink to\nconfigure Allsky\n***"
    -  make_image InstallationFailed   "red"      ""                 ""     ""        ""      ""         10      "red"     ""          ""        "***\nInstallation\nfailed\n***"
    -  make_image InstallationInProgress "yellow" ""                 80     ""        ""      ""         ""      ""        ""          ""        "***\nAllsky installation\nin progress.\nDo NOT\nchange anything.\n***"
    -  make_image RebootNeeded         "yellow"   ""                 ""     ""        ""      ""          7      "yellow"  ""          ""        "***\nReboot\nNeeded\n***"
     
    -fi
    +#          #1                   #2         #3                 #4     #5        #6      #7         #8      #9        #10         #11       #12
    +#          Basename             Text       Font               Font   Stroke    Stroke  Background Border  Border    Extensions  Image     Message
    +#                               Color      Name               Size   Color     Width   Color      Width   Color                 Size
    +#          ""                   "white"    "Helvetica-Bold"   128    "black"   2       "#404040"  0       "white"   ${ALL_EXTS} "959x719" ""
    +#          +--------------------+----------+------------------+------+---------+-------+----------+-------+---------+-----------+---------+--------------------------------------
    +make_image NotRunning           "red"      ""                 ""     ""        ""      ""         ""      ""        ""          ""        "Allsky\nis not\nrunning"
    +make_image DarkFrames           "green"    ""                 ""     "white"    1      "black"    ""      ""        ""          ""        "Camera\nis taking\ndark frames"
    +make_image StartingUp           "lime"     ""                 150    ""        ""      ""         10      "lime"    ""          ""        "Allsky\nis starting\nup"
    +make_image Restarting           "lime"     ""                 ""     ""        ""      ""          7      "lime"    ""          ""        "Allsky\nis restarting"
    +make_image CameraOffDuringDay   "#ffff4a"  ""                 ""     ""        ""      "gray"      5      "yellow"  ""          ""        "Camera\nis off\nduring the day"
    +make_image CameraOffDuringNight "#ffff4a"  ""                 ""     ""        ""      "gray"      5      "yellow"  ""          ""        "Camera\nis off\nat night"
    +make_image Error                "red"      ""                 80     ""        ""      ""         10      "red"     ""          ""        "ERROR\n\nSee the WebUI\nfor details"
    +
    +make_image ConfigurationNeeded  "yellow"   ""                 80     ""        ""      ""         ""      ""        ""          ""        "***\nUse the WebUI\n'Allsky Settings'\npage to\nconfigure Allsky\n***"
    +make_image InstallationFailed   "red"      ""                 ""     ""        ""      ""         10      "red"     ""          ""        "***\nInstallation\nfailed\n***"
    +make_image InstallationInProgress "yellow" ""                 80     ""        ""      ""         ""      ""        ""          ""        "***\nAllsky installation\nin progress.\nDo NOT\nchange anything.\n***"
    +make_image RebootNeeded         "yellow"   ""                 ""     ""        ""      ""          7      "yellow"  ""          ""        "***\nReboot\nNeeded\n***"
    +
    +exit 0
    diff --git a/scripts/installUpgradeFunctions.sh b/scripts/installUpgradeFunctions.sh
    index e62949096..b20501efe 100644
    --- a/scripts/installUpgradeFunctions.sh
    +++ b/scripts/installUpgradeFunctions.sh
    @@ -3,17 +3,66 @@
     # Shell variables and functions used by the installation and upgrade scripts.
     # This file is "source"d into others, and must be done AFTER source'ing variables.sh.
     
    +######################################### variables
    +
    +# export to keep shellcheck quiet
    +	# The login installing Allsky
    +export ALLSKY_OWNER=$( id --group --name )
    +export ALLSKY_GROUP=${ALLSKY_OWNER}
    +export WEBSERVER_OWNER="www-data"
    +export WEBSERVER_GROUP="${WEBSERVER_OWNER}"
    +export NEED_TO_UPDATE="XX_NEED_TO_UPDATE_XX"
    +
    +	# Central location for all master repository files.
    +export ALLSKY_REPO="${ALLSKY_HOME}/config_repo"
    +export REPO_WEBCONFIG_FILE="${ALLSKY_REPO}/${ALLSKY_WEBSITE_CONFIGURATION_NAME}.repo"
    +export REPO_SUDOERS_FILE="${ALLSKY_REPO}/sudoers.repo"
    +export ALLSKY_DEFINES_INC="allskyDefines.inc"
    +export REPO_WEBUI_DEFINES_FILE="${ALLSKY_REPO}/${ALLSKY_DEFINES_INC}.repo"
    +export REPO_LIGHTTPD_FILE="${ALLSKY_REPO}/lighttpd.conf.repo"
    +export REPO_AVI_FILE="${ALLSKY_REPO}/avahi-daemon.conf.repo"
    +export REPO_OPTIONS_FILE="${ALLSKY_REPO}/$( basename "${OPTIONS_FILE}" ).repo"
    +export REPO_ENV_FILE="${ALLSKY_REPO}/$( basename "${ALLSKY_ENV}" ).repo"
    +export REPO_WEBSITE_CONFIGURATION_FILE="${ALLSKY_REPO}/${ALLSKY_WEBSITE_CONFIGURATION_NAME}.repo"
    +
    +##### Information on prior Allsky versions and files.
    +	# Location of old-style WebUI and Website.
    +export OLD_WEBUI_LOCATION="/var/www/html"
    +export OLD_WEBSITE_LOCATION="${OLD_WEBUI_LOCATION}/allsky"
    +	# Directory of prior version of Allsky, if it exists.
    +export PRIOR_ALLSKY_DIR="$( dirname "${ALLSKY_HOME}" )/${ALLSKY_INSTALL_DIR}-OLD"
    +	# Prior "config" directory, if it exists.
    +export PRIOR_CONFIG_DIR="${PRIOR_ALLSKY_DIR}/$( basename "${ALLSKY_CONFIG}" )"
    +export PRIOR_REMOTE_WEBSITE_CONFIGURATION_FILE="${PRIOR_CONFIG_DIR}/${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_NAME}"
    +export PRIOR_PYTHON_VENV="${PRIOR_ALLSKY_DIR}/venv/lib"
    +export PRIOR_MYFILES_DIR="${ALLSKY_MYFILES_DIR/${ALLSKY_HOME}/${PRIOR_ALLSKY_DIR}}"
    +
    +	# Name of setting that determines version of Website config file.
    +export WEBSITE_CONFIG_VERSION="ConfigVersion"
    +	# Name of setting that holds the Allsky version.
    +export WEBSITE_ALLSKY_VERSION="config.AllskyVersion"
    +
    +	# Location of prior files varies by release; this is most recent location.
    +export PRIOR_CONFIG_FILE="${PRIOR_CONFIG_DIR}/config.sh"
    +export PRIOR_FTP_FILE="${PRIOR_CONFIG_DIR}/ftp-settings.sh"
    +
    +	# Location of lighttpd files.
    +export LIGHTTPD_LOG_DIR="/var/log/lighttpd"
    +export LIGHTTPD_LOG_FILE="${LIGHTTPD_LOG_DIR}/error.log"
    +export LIGHTTPD_CONFIG_FILE="/etc/lighttpd/lighttpd.conf"
    +
     ######################################### functions
     
     #####
     # Display a header surrounded by stars.
    -display_header() {
    +function display_header()
    +{
     	local HEADER="${1}"
     	local LEN
     	((LEN = ${#HEADER} + 8))		# 8 for leading and trailing "*** "
     	local STARS=""
     	while [[ ${LEN} -gt 0 ]]; do
    -		STARS="${STARS}*"
    +		STARS+="*"
     		((LEN--))
     	done
     	echo
    @@ -24,20 +73,22 @@ display_header() {
     }
     
     #####
    -calc_wt_size()
    +function calc_wt_size()
     {
    -	WT_WIDTH=$(tput cols)
    +	local WT_WIDTH=$( tput cols )
     	[[ ${WT_WIDTH} -gt 80 ]] && WT_WIDTH=80
    +	echo "${WT_WIDTH}"
     }
     
     #####
     # Get a Git version, stripping any trailing newline.
     # Return "" if none, or on error.
    -function get_Git_version() {
    +function get_Git_version()
    +{
     	local BRANCH="${1}"
     	local PACKAGE="${2}"
     	local VF="$( basename "${ALLSKY_VERSION_FILE}" )"
    -	local V="$(curl --show-error --silent "${GITHUB_RAW_ROOT}/${PACKAGE}/${BRANCH}/${VF}" | tr -d '\n\r')"
    +	local V="$( curl --show-error --silent "${GITHUB_RAW_ROOT}/${PACKAGE}/${BRANCH}/${VF}" | tr -d '\n\r' )"
     	# "404" means the branch isn't found since all new branches have a version file.
     	[[ ${V} != "404: Not Found" ]] && echo -n "${V}"
     }
    @@ -49,13 +100,14 @@ function get_Git_version() {
     # in case we ever change it.
     
     #####
    -# Get the version from a local file, if it exists.
    -function get_version() {
    +# Get the version from a local file, if it exists.  If not, get from default file.
    +function get_version()
    +{
     	local F="${1}"
     	if [[ -z ${F} ]]; then
     		F="${ALLSKY_VERSION_FILE}"		# default
     	else
    -		[[ ${F:1,-1} == "/" ]] && F="${F}$(basename "${ALLSKY_VERSION_FILE}")"
    +		[[ ${F:1,-1} == "/" ]] && F+="$( basename "${ALLSKY_VERSION_FILE}" )"
     	fi
     	if [[ -f ${F} ]]; then
     		# Sometimes the branch file will have both "master" and "dev" on two lines.
    @@ -67,12 +119,31 @@ function get_version() {
     
     #####
     # Get the branch using git.
    -function get_branch() {
    +function get_branch()
    +{
     	local H="${1:-${ALLSKY_HOME}}"
     	echo "$( cd "${H}" || exit; git rev-parse --abbrev-ref HEAD )"
     }
     
     
    +#####
    +# Get a shell variable's value.  The variable can have optional spaces and tabs before it.
    +# This function is useful when we can't "source" the file.
    +function get_variable()
    +{
    +	local VARIABLE="${1}"
    +	local FILE="${2}"
    +	local LINE=""
    +	local SEARCH_STRING="^[ 	]*${VARIABLE}="
    +	if ! LINE="$( /bin/grep -E "${SEARCH_STRING}" "${FILE}" 2>/dev/null )" ; then
    +		return 1
    +	fi
    +
    +	echo "${LINE}" | sed -e "s/${SEARCH_STRING}//" -e 's/"//g'
    +	return 0
    +}
    +
    +
     #####
     # Display a message of various types in appropriate colors.
     # Used primarily in installation scripts.
    @@ -140,15 +211,15 @@ function display_msg()
     	fi
     
     	if [[ ${STARS} == "true" ]]; then
    -		MSG="${MSG}\n"
    -		MSG="${MSG}**********\n"
    -		MSG="${MSG}${MESSAGE}\n"
    -		MSG="${MSG}**********${NC}\n"
    +		MSG+="\n"
    +		MSG+="**********\n"
    +		MSG+="${MESSAGE}\n"
    +		MSG+="**********${NC}\n"
     
    -		LOGMSG="${LOGMSG}\n"
    -		LOGMSG="${LOGMSG}**********\n"
    -		LOGMSG="${LOGMSG}${MESSAGE}\n"
    -		LOGMSG="${LOGMSG}**********\n"
    +		LOGMSG+="\n"
    +		LOGMSG+="**********\n"
    +		LOGMSG+="${MESSAGE}\n"
    +		LOGMSG+="**********\n"
     	fi
     
     	[[ ${LOG_ONLY} == "false" ]] && echo -e "${MSG}${MESSAGE2}"
    @@ -165,7 +236,7 @@ function display_msg()
     	# I don't know how to replace "\n" with an
     	# actual newline in sed, and there HAS to be a better way to strip the
     	# escape sequences.
    -	# I simply replace actual escape characters in the input with "033" then 
    +	# I simply replace actual escape characters in the input with "033" then
     	# replace "033[" with "033X".
     	# Feel free to improve...
     
    @@ -183,7 +254,7 @@ function display_msg()
     
     		# Outer "echo -e" handles "\n" (2 characters) in input.
     		# No "-e" needed on inner "echo".
    -		echo -e "$( echo "${LOGMSG}${MESSAGE2}" |
    +		echo -e "$( echo "$(date) ${LOGMSG}${MESSAGE2}" |
     			sed -e "s/${ESC}/033/g" -e "s/033\[/033X/g" \
     				-e "s/${G}//g" \
     				-e "s/${Y}//g" \
    @@ -192,18 +263,932 @@ function display_msg()
     				-e "s/${N}//g" \
     		)"
     	else
    -		echo "${LOGMSG}${MESSAGE2}"
    +		echo "$(date) ${LOGMSG}${MESSAGE2}"
     	fi >>  "${DISPLAY_MSG_LOG}"
     }
     
     
    +# The various upload protocols need different variables defined.
    +# For the specified protocol, make sure the specified variable is defined.
    +function check_PROTOCOL()
    +{
    +	local P="${1}"	# Protocol
    +	local V="${2}"	# Variable
    +	local T="${3}"	# Type (web or server)
    +	local N="${4}"	# Name of setting  
    +	local VALUE="$( settings ".${V}" "${ALLSKY_ENV}" )"
    +	if [[ -z ${VALUE} ]]; then
    +		echo "${T} Protocol (${P}) set but not '${N}'."
    +		echo "Uploads will not work until this is fixed."
    +		echo "FIX: Set '${N}'."
    +		return 1
    +	fi
    +	return 0
    +}
    +# Check variables are correct for a remote server.
    +# Return 0 for OK, 1 for warning, 2 for error.
    +function check_remote_server()
    +{
    +	check_for_env_file || return 1
     
    -######################################### variables
    +	local TYPE="${1}"
    +	local sTYPE
    +	if [[ ${TYPE} == "REMOTEWEBSITE" ]]; then
    +		sTYPE="Remote Website"
    +	else
    +		sTYPE="Remote Server"
    +	fi
    +	local CORRECTED="Uploads will not work until this is corrected."
     
    -# export to keep shellcheck quiet
    -export ALLSKY_OWNER=$(id --group --name)		# The login installing Allsky
    -export ALLSKY_GROUP=${ALLSKY_OWNER}
    -export WEBSERVER_GROUP="www-data"
    -export REPO_WEBCONFIG_FILE="${ALLSKY_REPO}/${ALLSKY_WEBSITE_CONFIGURATION_NAME}.repo"
    -export OLD_WEBUI_LOCATION="/var/www/html"		# location of old-style WebUI
    -export OLD_WEBSITE_LOCATION="${OLD_WEBUI_LOCATION}/allsky"
    +	local USE="$( settings ".use${TYPE,,}" )"
    +	if [[ ${USE} != "true" ]]; then
    +		return 0
    +	fi
    +
    +	local RET=0
    +	local PROTOCOL="$( settings ".${TYPE,,}protocol" )"
    +	case "${PROTOCOL}" in
    +		"")
    +			echo "${sTYPE} is being used but has no Protocol."
    +			echo "${CORRECTED}"
    +			echo "FIX: Either disable the remote Website/server or specify the protocol."
    +			return 2
    +			;;
    +
    +		ftp | ftps | sftp | scp | rsync)
    +			check_PROTOCOL "${PROTOCOL}" "${TYPE}_HOST" "${sTYPE}" "Server Name" || RET=1
    +			check_PROTOCOL "${PROTOCOL}" "${TYPE}_USER" "${sTYPE}" "User Name" || RET=1
    +			if [[ ${PROTOCOL} == "scp" || ${PROTOCOL} == "rsync" ]]; then
    +				if check_PROTOCOL "${PROTOCOL}" "${TYPE}_SSH_KEY_FILE" "SSH Key File" "${sTYPE}" \
    +						&& [[ ! -e ${SSH_KEY_FILE} ]]; then
    +					echo -n "${sTYPE} Protocol (${PROTOCOL}) set but '${TYPE}_SSH_KEY_FILE'"
    +					echo    " (${SSH_KEY_FILE}) does not exist."
    +					echo    "${CORRECTED}"
    +					echo    "FIX: Create the file or use a different protocol."
    +					RET=1
    +				fi
    +			else
    +				check_PROTOCOL "${PROTOCOL}" "${TYPE}_PASSWORD" "${sTYPE}" "Password" || RET=1
    +				if [[ ${PROTOCOL} == "ftp" ]]; then
    +					echo "${sTYPE} Protocol set to insecure 'ftp'."
    +					echo "FIX: Using 'ftps' or 'sftp' if possible."
    +					RET=1
    +				fi
    +			fi
    +			;;
    +
    +		s3 | gcs)
    +			P="${PROTOCOL^^}"
    +			if [[ ${PROTOCOL} == "s3" ]] &&
    +				check_PROTOCOL "${PROTOCOL}" "${TYPE}_AWS_CLI_DIR" "${sTYPE}" "AWS CLI Directory" \
    +				&& [[ ! -e ${AWS_CLI_DIR} ]]; then
    +
    +				echo -n "${sTYPE} Protocol (${PROTOCOL}) set but '${TYPE}_AWS_CLI_DIR'"
    +				echo    "(${AWS_CLI_DIR}) does not exist."
    +				echo    "${CORRECTED}"
    +				echo    "FIX: Create the directory and its contents or use a different protocol."
    +				RET=1
    +			fi
    +			check_PROTOCOL "${PROTOCOL}" "${TYPE}_${P}BUCKET" "${sTYPE}" "${P} Bucket" || RET=1
    +			check_PROTOCOL "${PROTOCOL}" "${TYPE}_${P}ACL" "${sTYPE}" "${P} ACL" || RET=1
    +			;;
    +
    +		*)
    +			echo -n "${sTYPE} Protocol (${PROTOCOL}) is not blank or one of:"
    +			echo    " ftp, ftps, sftp, scp, rsync, s3, gcs."
    +			echo    "${CORRECTED}"
    +			echo    "FIX: Use a valid protocol."
    +			RET=1
    +			;;
    +	esac
    +
    +	REMOTE_PORT="$( get_variable "${TYPE}_PORT" "${ALLSKY_ENV}" )"
    +	if [[ -n ${REMOTE_PORT} ]] && ! is_number "${REMOTE_PORT}" ; then
    +		echo "${sTYPE} Port (${REMOTE_PORT}) must be a number."
    +		echo "${CORRECTED}"
    +		echo "FIX: Use a valid port number."
    +		RET=1
    +	fi
    +
    +	return "${RET}"
    +}
    +
    +
    +####
    +# Update a json file.
    +#	field, new value, file, [type]
    +# or
    +#	-d field, "" value, file
    +function update_json_file()		# [-d] field, new value, file, [type]
    +{
    +	local M  DELETE  FIELD  FILE  TEMP  NEW_VALUE
    +	local ACTION  TYPE  DOUBLE_QUOTE  ERR_MSG  RET
    +
    +	M="${ME:-${FUNCNAME[0]}}"
    +
    +	if [[ ${1} == "-d" ]]; then
    +		DELETE="true"
    +		shift
    +	else
    +		DELETE="false"
    +	fi
    +
    +	FIELD="${1}"
    +	if [[ ${FIELD:0:1} != "." ]]; then
    +		echo "${M}: Field names must begin with period '.' (Field='${FIELD}')" >&2
    +		return 1
    +	fi
    +
    +	FILE="${3:-${SETTINGS_FILE}}"
    +	TEMP="/tmp/$$"
    +
    +	if [[ ${DELETE} == "true" ]]; then
    +		NEW_VALUE="(delete)"	# only used in error message below.
    +		ACTION="del(${FIELD})"
    +	else
    +		NEW_VALUE="${2}"
    +		TYPE="${4}"
    +
    +		DOUBLE_QUOTE='"'
    +
    +		if [[ -n ${TYPE} ]]; then
    +			# These don't need quotes.
    +			if [[ ${TYPE} == "boolean" || ${TYPE} == "percent" ||
    +				  ${TYPE} == "number" || ${TYPE} == "integer" || ${TYPE} == "float" ]]; then
    +				DOUBLE_QUOTE=""
    +			fi
    +
    +			# If the TYPE wasn't passed to us, do our best to determine if
    +			# it's a boolean or number.
    +		elif [[ ${NEW_VALUE} == "true" || ${NEW_VALUE} == "false" ]] ||
    +				is_number "${NEW_VALUE}" ; then
    +			DOUBLE_QUOTE=""
    +		fi
    +		ACTION="${FIELD} = ${DOUBLE_QUOTE}${NEW_VALUE}${DOUBLE_QUOTE}"
    +	fi
    +
    +	ERR_MSG="$( jq --indent 4 "${ACTION}" "${FILE}" 2>&1 > "${TEMP}" )"
    +	RET=$?
    +	if [[ ${RET} -eq 0 ]]; then
    +		# Have to use "cp" instead of "mv" to keep any hard link.
    +		cp "${TEMP}" "${FILE}"
    +	else
    +		MSG="Unable to [$ACTION] json value of '${FIELD}' to '${NEW_VALUE}' in '${FILE}': ${ERR_MSG}"
    +		echo "${M}: ${MSG}" >&2
    +	fi
    +	rm "${TEMP}"
    +
    +	return "${RET}"
    +}
    +
    +
    +####
    +# Update a field in an array or delete the array index the field is at.
    +function update_array_field()
    +{
    +	local FILE="${1}"
    +	local ARRAY="${2}"
    +	local FIELD="${3}"			# may be ""
    +	local VALUE="${4}"
    +	local NEW_VALUE="${5}"		# a value or "--delete"
    +
    +	local I="$( getJSONarrayIndex "${FILE}" "${ARRAY}" "${VALUE}" )"
    +	[[ ${I} -eq -1 ]] && return
    +
    +	if [[ ${NEW_VALUE} == "--delete" ]]; then
    +		update_json_file -d ".${ARRAY}[${I}]" "" "${FILE}"
    +	else
    +		local URL=".${ARRAY}[${I}].${FIELD}"
    +		local V="$( settings "${URL}" "${FILE}" )"
    +		if [[ ${V} != "${NEW_VALUE}" ]]; then
    +			update_json_file "${URL}" "${NEW_VALUE}" "${FILE}"
    +		fi
    +	fi
    +}
    +
    +
    +# Replace all the ${NEED_TO_UPDATE} placeholders.
    +function replace_website_placeholders()
    +{
    +	local TYPE="${1}"		# "local" or "remote" Website
    +	local FILE="${2}"
    +	if [[ -z ${FILE} ]]; then
    +		if [[ ${TYPE} == "local" ]]; then
    +			FILE="${ALLSKY_WEBSITE_CONFIGURATION_FILE}"
    +		else
    +			FILE="${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}"
    +		fi
    +	fi
    +
    +	# Get the array index for the mini-timelapse.
    +
    +	local PARENT  FIELD  INDEX  MINI_TLAPSE_DISPLAY  MINI_TLAPSE_URL
    +	local MINI_TLAPSE_DISPLAY_VALUE  MINI_TLAPSE_URL_VALUE
    +	PARENT="homePage.leftSidebar"
    +	FIELD="Mini-timelapse"
    +	INDEX=$( getJSONarrayIndex "${FILE}" "${PARENT}" "${FIELD}" )
    +	if [[ ${INDEX} -ge 0 ]]; then
    +		MINI_TLAPSE_DISPLAY="${PARENT}[${INDEX}].display"
    +		MINI_TLAPSE_URL="${PARENT}[${INDEX}].url"
    +		TIMELAPSE_MINI_IMAGES="$( settings ".minitimelapsenumimages" )"
    +		if [[ ${TIMELAPSE_MINI_IMAGES:-0} -eq 0 ]]; then
    +			MINI_TLAPSE_DISPLAY_VALUE="false"
    +			MINI_TLAPSE_URL_VALUE=""
    +		else
    +			MINI_TLAPSE_DISPLAY_VALUE="true"
    +			if [[ ${DO_REMOTE_WEBSITE} == "true" ]]; then
    +				MINI_TLAPSE_URL_VALUE="mini-timelapse.mp4"
    +			else
    +				#shellcheck disable=SC2153
    +				MINI_TLAPSE_URL_VALUE="/${IMG_DIR}/mini-timelapse.mp4"
    +			fi
    +		fi
    +	else
    +		MSG="Unable to update '${FIELD}' in ${FILE}; ignoring."
    +		display_msg --log warning "${MSG}"
    +		# bogus settings that won't do anything
    +		MINI_TLAPSE_DISPLAY="x"
    +		MINI_TLAPSE_URL="x"
    +		MINI_TLAPSE_DISPLAY_VALUE=""
    +		MINI_TLAPSE_URL_VALUE=""
    +	fi
    +
    +	# For these setting, check if it's == ${NEED_TO_UPDATE}.
    +	# If so, and the settings file's value isn't null, update it in the config file.
    +	# If the config file has a value, use it, even if it's "".
    +
    +	local TEMP  LATITUDE  LONGITUDE  AURORAMAP  LOCATION  OWNER  CAMERA
    +	local LENS  COMPUTER  IMAGE_NAME
    +
    +	LATITUDE="$( settings ".config.latitude" "${FILE}" )"
    +	if [[ ${LATITUDE} == "${NEED_TO_UPDATE}" ]]; then
    +		# Convert latitude and longitude to use N, S, E, W.
    +		TEMP="$( settings ".latitude" )"
    +		if [[ -n ${TEMP} ]]; then
    +			LATITUDE="$( convertLatLong "${TEMP}" "latitude" )"
    +		fi
    +	fi
    +	[[ -z ${LATITUDE} ]] && display_msg --log warning "latitude is empty"
    +
    +	LONGITUDE="$( settings ".config.longitude" "${FILE}" )"
    +	if [[ ${LONGITUDE} == "${NEED_TO_UPDATE}" ]]; then
    +		TEMP="$( settings ".longitude" )"
    +		if [[ -n ${TEMP} ]]; then
    +			LONGITUDE="$( convertLatLong "${TEMP}" "longitude" )"
    +		fi
    +	fi
    +	[[ -z ${LONGITUDE} ]] && display_msg --log warning "longitude is empty"
    +
    +	if [[ ${LATITUDE:1,-1} == "S" ]]; then			# last character
    +		AURORAMAP="south"
    +	else
    +		AURORAMAP="north"
    +	fi
    +
    +	LOCATION="$( settings ".config.location" "${FILE}" )"
    +	if [[ ${LOCATION} == "${NEED_TO_UPDATE}" ]]; then
    +		TEMP="$( settings ".location" )"
    +		if [[ -n ${TEMP} ]]; then
    +			LOCATION="${TEMP}"
    +		fi
    +	fi
    +
    +	OWNER="$( settings ".config.owner" "${FILE}" )"
    +	if [[ ${OWNER} == "${NEED_TO_UPDATE}" ]]; then
    +		TEMP="$( settings ".owner" )"
    +		if [[ -n ${TEMP} ]]; then
    +			OWNER="${TEMP}"
    +		fi
    +	fi
    +
    +	CAMERA="$( settings ".config.camera" "${FILE}" )"
    +	if [[ ${CAMERA} == "${NEED_TO_UPDATE}" ]]; then
    +		# TYPE and MODEL are already in the environment.
    +		CAMERA="${CAMERA_TYPE} ${CAMERA_MODEL}"
    +	fi
    +
    +	LENS="$( settings ".config.lens" "${FILE}" )"
    +	if [[ ${LENS} == "${NEED_TO_UPDATE}" ]]; then
    +		TEMP="$( settings ".lens" )"
    +		if [[ -n ${TEMP} ]]; then
    +			LENS="${TEMP}"
    +		fi
    +	fi
    +
    +	COMPUTER="$( settings ".config.computer" "${FILE}" )"
    +	if [[ ${COMPUTER} == "${NEED_TO_UPDATE}" ]]; then
    +		TEMP="$( settings ".computer" )"
    +		if [[ -n ${TEMP} ]]; then
    +			COMPUTER="${TEMP}"
    +		fi
    +	fi
    +
    +	if [[ ${TYPE} == "local" ]]; then
    +		#shellcheck disable=SC2153
    +		IMAGE_NAME="/${IMG_DIR}/${FULL_FILENAME}"
    +	else
    +		IMAGE_NAME="${FULL_FILENAME}"
    +	fi
    +
    +	"${ALLSKY_SCRIPTS}/updateWebsiteConfig.sh" --verbosity silent \
    +		--config "${FILE}" \
    +		config.imageName			"imageName"			"${IMAGE_NAME}" \
    +		config.latitude				"latitude"			"${LATITUDE}" \
    +		config.longitude			"longitude"			"${LONGITUDE}" \
    +		config.auroraMap			"auroraMap"			"${AURORAMAP}" \
    +		config.location				"location"			"${LOCATION}" \
    +		config.owner				"owner" 			"${OWNER}" \
    +		config.camera				"camera"			"${CAMERA}" \
    +		config.lens					"lens"				"${LENS}" \
    +		config.computer				"computer"			"${COMPUTER}" \
    +		config.AllskyVersion		"AllskyVersion"		"${ALLSKY_VERSION}" \
    +		${MINI_TLAPSE_DISPLAY}		"mini_display"		"${MINI_TLAPSE_DISPLAY_VALUE}" \
    +		${MINI_TLAPSE_URL}			"mini_url"			"${MINI_TLAPSE_URL_VALUE}"
    +}
    +
    +
    +####
    +# Prepare a local Website:
    +#	Update the config file by replacing placeholders.
    +#	Copy data.json.
    +function prepare_local_website()
    +{
    +	local FORCE="${1}"
    +
    +	display_msg --log progress "Creating default ${ALLSKY_WEBSITE_CONFIGURATION_NAME}."
    +
    +	# Make sure there's a config file.
    +	if [[ ! -s ${ALLSKY_WEBSITE_CONFIGURATION_FILE} || ${FORCE} == "--force" ]]; then
    +		cp "${REPO_WEBSITE_CONFIGURATION_FILE}" "${ALLSKY_WEBSITE_CONFIGURATION_FILE}"
    +	fi
    +
    +	replace_website_placeholders "local" "${ALLSKY_WEBSITE_CONFIGURATION_FILE}"
    +}
    +
    +
    +####
    +# Update a Website configuration file from old to current version.
    +function update_old_website_config_file()
    +{
    +	local FILE PRIOR_VERSION CURRENT_VERSION
    +
    +	FILE="${1}"
    +	PRIOR_VERSION="${2}"
    +	CURRENT_VERSION="${3}"
    +
    +	# Current version: 2
    +	if [[ ${PRIOR_VERSION} -eq 1 ]]; then
    +		# Deletions:
    +		update_json_file -d ".AllskyWebsiteVersion" "" "${FILE}"
    +		update_json_file -d ".homePage.onPi" "" "${FILE}"
    +		update_array_field "${FILE}" "homePage.popoutIcons" "variable" "AllskyWebsiteVersion" "--delete"
    +
    +		# Additions:
    +		# Add in same place as new file
    +		local NEW='      \"thumbnailsizex\": 100,\
    +        \"thumbnailsizey\": 75,\
    +        \"thumbnailsortorder\": \"ascending\",'
    +		sed -i "/\"leftSidebar\"/i\ ${NEW}" "${FILE}"
    +
    +		# Changes:
    +		for i in "videos" "keograms" "startrails"; do
    +			update_array_field "${FILE}" "homePage.leftSidebar" "url" "${i}" "${i}/"
    +		done
    +	fi
    +
    +	# Set to current config and Allsky versions.
    +	update_json_file ".${WEBSITE_CONFIG_VERSION}" "${CURRENT_VERSION}" "${FILE}"
    +	update_json_file ".${WEBSITE_ALLSKY_VERSION}" "${ALLSKY_VERSION}" "${FILE}"
    +}
    +
    +####
    +# Create the lighttpd configuration file.
    +function create_lighttpd_config_file()
    +{
    +	local TMP="/tmp/x"
    +
    +	sudo rm -f "${TMP}"
    +	sed \
    +		-e "s;XX_ALLSKY_WEBUI_XX;${ALLSKY_WEBUI};g" \
    +		-e "s;XX_WEBSERVER_OWNER_XX;${WEBSERVER_OWNER};g" \
    +		-e "s;XX_WEBSERVER_GROUP_XX;${WEBSERVER_GROUP};g" \
    +		-e "s;XX_ALLSKY_HOME_XX;${ALLSKY_HOME};g" \
    +		-e "s;XX_ALLSKY_IMAGES_XX;${ALLSKY_IMAGES};g" \
    +		-e "s;XX_ALLSKY_CONFIG_XX;${ALLSKY_CONFIG};g" \
    +		-e "s;XX_ALLSKY_WEBSITE_XX;${ALLSKY_WEBSITE};g" \
    +		-e "s;XX_ALLSKY_DOCUMENTATION_XX;${ALLSKY_DOCUMENTATION};g" \
    +		-e "s;XX_ALLSKY_OVERLAY_XX;${ALLSKY_OVERLAY};g" \
    +		-e "s;XX_MY_OVERLAY_TEMPLATES_XX;${MY_OVERLAY_TEMPLATES};g" \
    +			"${REPO_LIGHTTPD_FILE}"  >  "${TMP}"
    +	sudo install -m 0644 "${TMP}" "${LIGHTTPD_CONFIG_FILE}" && rm -f "${TMP}"
    +}
    +
    +####
    +# Create the lighttpd log file with permissions so user can update it.
    +function create_lighttpd_log_file()
    +{
    +	display_msg --log progress "Creating new ${LIGHTTPD_LOG_FILE}."
    +
    +	# Remove any old log files.
    +	# Start off with a 0-length log file the user can write to.
    +	sudo chmod 755 "${LIGHTTPD_LOG_DIR}"
    +	sudo rm -fr "${LIGHTTPD_LOG_DIR}"/*
    +	sudo truncate -s 0 "${LIGHTTPD_LOG_FILE}"
    +	sudo chmod 664 "${LIGHTTPD_LOG_FILE}"
    +	sudo chown "${WEBSERVER_GROUP}:${ALLSKY_GROUP}" "${LIGHTTPD_LOG_FILE}"
    +}
    +
    +####
    +# Check for size of RAM+swap during installation (Issue # 969)
    +# and ask the user to increase if not "big enough".
    +# recheck_swap() is is referenced in the Allsky Documentation and can
    +# optionally be called after installation to adjust swap space.
    +function recheck_swap()
    +{
    +	check_swap "after_install" "prompt"
    +}
    +function check_swap()
    +{
    +	# global: TITLE  WT_WIDTH
    +	local SWAP_CONFIG_FILE="/etc/dphys-swapfile"
    +	local CALLED_FROM PROMPT
    +	CALLED_FROM="${1}"
    +	PROMPT="${2:-false}"
    +
    +	if [[ ${CALLED_FROM} == "install" ]]; then
    +		declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" && ${PROMPT} == "false" ]] && return
    +	fi
    +
    +	[[ -z ${WT_WIDTH} ]] && WT_WIDTH="$( calc_wt_size )"
    +	local RAM_SIZE  DESIRED_COMBINATION  SUGGESTED_SWAP_SIZE  CURRENT_SWAP
    +	local AMT  M  MSG  SWAP_SIZE  CURRENT_MAX
    +
    +	# This can return "total_mem is unknown" if the OS is REALLY old.
    +	RAM_SIZE="$( vcgencmd get_config total_mem )"
    +	if [[ ${RAM_SIZE} =~ "unknown" ]]; then
    +		# Note: This doesn't produce exact results.  On a 4 GB Pi, it returns 3.74805.
    +		RAM_SIZE=$( free --mebi | awk '{if ($1 == "Mem:") {print $2; exit 0} }' )		# in MB
    +	else
    +		RAM_SIZE="${RAM_SIZE//total_mem=/}"
    +	fi
    +	DESIRED_COMBINATION=$((1024 * 5))		# desired minimum memory + swap
    +	SUGGESTED_SWAP_SIZE=0
    +	for i in 512 1024 2048 4096		# 8192 and above don't need any swap
    +	do
    +		if [[ ${RAM_SIZE} -le ${i} ]]; then
    +			SUGGESTED_SWAP_SIZE=$((DESIRED_COMBINATION - i))
    +			break
    +		fi
    +	done
    +	display_msg --logonly info "RAM_SIZE=${RAM_SIZE}, SUGGESTED_SWAP_SIZE=${SUGGESTED_SWAP_SIZE}."
    +
    +	# Not sure why, but displayed swap is often 1 MB less than what's in /etc/dphys-swapfile
    +	CURRENT_SWAP=$( free --mebi | awk '{if ($1 == "Swap:") {print $2 + 1; exit 0} }' )	# in MB
    +	CURRENT_SWAP=${CURRENT_SWAP:-0}
    +	if [[ ${CURRENT_SWAP} -lt ${SUGGESTED_SWAP_SIZE} || ${PROMPT} == "true" ]]; then
    +
    +		[[ -z ${FUNCTION} ]] && sleep 2		# give user time to read prior messages
    +		if [[ ${CURRENT_SWAP} -eq 1 ]]; then
    +			CURRENT_SWAP=0
    +			AMT="no"
    +			M="added"
    +		else
    +			AMT="${CURRENT_SWAP} MB of"
    +			M="increased"
    +		fi
    +		MSG="\nYour Pi currently has ${AMT} swap space."
    +		MSG+="\nBased on your memory size of ${RAM_SIZE} MB,"
    +		if [[ ${CURRENT_SWAP} -ge ${SUGGESTED_SWAP_SIZE} ]]; then
    +			SUGGESTED_SWAP_SIZE=${CURRENT_SWAP}
    +			MSG+=" there is no need to change anything, but you can if you would like."
    +		else
    +			MSG+=" we suggest ${SUGGESTED_SWAP_SIZE} MB of swap"
    +			MSG+=" to decrease the chance of timelapse and other failures."
    +			MSG+="\n\nDo you want swap space ${M}?"
    +			MSG+="\n\nYou may change the amount of swap space by changing the number below."
    +		fi
    +
    +		SWAP_SIZE=$( whiptail --title "${TITLE}" --inputbox "${MSG}" 18 "${WT_WIDTH}" \
    +			"${SUGGESTED_SWAP_SIZE}" 3>&1 1>&2 2>&3 )
    +		# If the suggested swap was 0 and the user added a number but didn't first delete the 0,
    +		# do it now so we don't have numbers like "0256".
    +		[[ ${SWAP_SIZE:0:1} == "0" ]] && SWAP_SIZE="${SWAP_SIZE:1}"
    +
    +		if [[ -z ${SWAP_SIZE} || ${SWAP_SIZE} == "0" ]]; then
    +			if [[ ${CURRENT_SWAP} -eq 0 && ${SUGGESTED_SWAP_SIZE} -gt 0 ]]; then
    +				display_msg --log warning "With no swap space you run the risk of programs failing."
    +			else
    +				display_msg --log info "Swap will remain at ${CURRENT_SWAP}."
    +			fi
    +		else
    +			display_msg --log progress "Setting swap space to ${SWAP_SIZE} MB."
    +			sudo dphys-swapfile swapoff					# Stops the swap file
    +			sudo sed -i "/CONF_SWAPSIZE/ c CONF_SWAPSIZE=${SWAP_SIZE}" "${SWAP_CONFIG_FILE}"
    +
    +			CURRENT_MAX="$( get_variable "CONF_MAXSWAP" "${SWAP_CONFIG_FILE}" )"
    +			# TODO: Can we determine the default max rather than hard-code it?
    +			CURRENT_MAX="${CURRENT_MAX:-2048}"
    +			if [[ ${CURRENT_MAX} -lt ${SWAP_SIZE} ]]; then
    +				if [[ ${DEBUG} -gt 0 ]]; then
    +					display_msg --log debug "Increasing max swap size to ${SWAP_SIZE} MB."
    +				fi
    +				sudo sed -i "/CONF_MAXSWAP/ c CONF_MAXSWAP=${SWAP_SIZE}" "${SWAP_CONFIG_FILE}"
    +			fi
    +
    +			sudo dphys-swapfile setup  > /dev/null		# Sets up new swap file
    +			sudo dphys-swapfile swapon					# Turns on new swap file
    +		fi
    +	else
    +		MSG="Size of current swap (${CURRENT_SWAP} MB) is sufficient; no change needed."
    +		display_msg --logonly info "${MSG}"
    +	fi
    +
    +	if [[ ${CALLED_FROM} == "install" ]]; then
    +		STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
    +	fi
    +}
    +
    +
    +####
    +
    +INITIAL_FSTAB_STRING="tmpfs ${ALLSKY_TMP} tmpfs"
    +
    +# Is the tmp directory mounted?
    +function is_mounted()
    +{
    +	local TMP="${1}"
    +
    +	mount | grep --quiet "${TMP}"
    +}
    +function umount_tmp()
    +{
    +	local TMP="${1}"
    +
    +	sudo umount -f "${TMP}" 2> /dev/null ||
    +		{
    +			sudo systemctl restart smbd 2> /dev/null
    +			sudo umount -f "${TMP}" 2> /dev/null
    +		}
    +}
    +
    +####
    +function recheck_tmp()
    +{
    +	check_tmp "after_install"
    +}
    +####
    +# Check if prior ${ALLSKY_TMP} was a memory filesystem.
    +# If not, offer to make it one.
    +function check_tmp()
    +{
    +	# global: TITLE  WT_WIDTH
    +	local PROMPT  CALLED_FROM
    +	CALLED_FROM="${1}"
    +
    +	if [[ ${CALLED_FROM} == "install" ]]; then
    +		declare -n v="${FUNCNAME[0]}"; [[ ${v} == "true" ]] && return
    +	fi
    +
    +	[[ -z ${WT_WIDTH} ]] && WT_WIDTH="$( calc_wt_size )"
    +	local STRING  SIZE  D  MSG
    +
    +	# If the prior ${ALLSKY_TMP} was a memory filesystem it will have an entry
    +	# in /etc/fstab with ${ALLSKY_TMP} in it, even if it's not currently mounted.
    +	if grep --quiet "^${INITIAL_FSTAB_STRING}" /etc/fstab ; then
    +		MSG="${ALLSKY_TMP} is currently a memory filesystem; no change needed."
    +		display_msg --logonly info "${MSG}"
    +
    +		# If there's a prior Allsky version and it's tmp directory is mounted,
    +		# try to unmount it, but that often gives an error that it's busy,
    +		# which isn't really a problem since it'll be unmounted at the reboot.
    +		# We know from the grep above that /etc/fstab has ${ALLSKY_TMP}
    +		# but the mount point is currently in the PRIOR Allsky.
    +		D="${PRIOR_ALLSKY_DIR}/tmp"
    +		if [[ -d "${D}" ]] && mount | grep --silent "${D}" ; then
    +			# The Samba daemon is one known cause of "target busy".
    +			umount_tmp "${D}"
    +		fi
    +
    +		if [[ ${CALLED_FROM} == "install" ]]; then
    +			STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
    +		fi
    +
    +		# If the new Allsky's ${ALLSKY_TMP} is already mounted, don't do anything.
    +		# This would be the case during an upgrade.
    +		if mount | grep --silent "${ALLSKY_TMP}" ; then
    +			display_msg --logonly info "${ALLSKY_TMP} already mounted."
    +			return 0
    +		fi
    +
    +		check_and_mount_tmp		# works on new ${ALLSKY_TMP}
    +		return 0
    +	fi
    +
    +	SIZE=75		# MB - should be enough
    +	MSG="Putting the ${ALLSKY_TMP} director and its contents into memory drastically"
    +	MSG+=" decreases the number of writes to the SD card, increasing its life."
    +	MSG+="\n\nDo you want to do this?"
    +	MSG+="\n\nNote: anything in that directory will be deleted whenever the Pi is rebooted,"
    +	MSG+=" but that's not an issue since the directory only contains temporary files."
    +	if whiptail --title "${TITLE}" --yesno "${MSG}" 15 "${WT_WIDTH}"  3>&1 1>&2 2>&3; then
    +		STRING="${INITIAL_FSTAB_STRING} size=${SIZE}M,noatime,lazytime,nodev,"
    +		STRING+="nosuid,mode=775,uid=${ALLSKY_OWNER},gid=${WEBSERVER_GROUP}"
    +		if ! echo "${STRING}" | sudo tee -a /etc/fstab > /dev/null ; then
    +			display_msg --log error "Unable to update /etc/fstab"
    +			return 1
    +		fi
    +		check_and_mount_tmp
    +		display_msg --log progress "${ALLSKY_TMP} is now in memory."
    +	else
    +		MSG="The ${ALLSKY_TMP} directory and its contnts will remain on the SD card."
    +		display_msg --log info "${MSG}"
    +		mkdir -p "${ALLSKY_TMP}"
    +	fi
    +
    +	if [[ ${CALLED_FROM} == "install" ]]; then
    +		STATUS_VARIABLES+=("${FUNCNAME[0]}='true'\n")
    +	fi
    +}
    +
    +####
    +# Prompt for either latitude or longitude, and make sure it's a valid entry.
    +function prompt_for_lat_long()
    +{
    +	local PROMPT="${1}"
    +	local SETTING_NAME="${2}"
    +	local WEBUI_SETTING_LABEL="${3}"
    +	local DEFAULT="${4}"
    +	local ERROR_MSG=""   VALUE  M
    +
    +	[[ -z ${WT_WIDTH} ]] && WT_WIDTH="$( calc_wt_size )"
    +	[[ -z ${TITLE} ]] && TITLE="Enter ${SETTING_NAME}"
    +
    +	while :
    +	do
    +		M="${ERROR_MSG}${PROMPT}"
    +		VALUE=$( whiptail --title "${TITLE}" --inputbox "${M}" 18 "${WT_WIDTH}" "${DEFAULT}" 3>&1 1>&2 2>&3 )
    +		if [[ -z ${VALUE} ]]; then
    +			# Let the user not enter anything.
    +			# A warning message is printed by our invoker.
    +			echo ""
    +			return
    +
    +		elif VALUE="$( convertLatLong "${VALUE}" "${SETTING_NAME}" 2>&1 )" ; then
    +			update_json_file ".${SETTING_NAME}" "${VALUE}" "${SETTINGS_FILE}"
    +			display_msg --log progress "${WEBUI_SETTING_LABEL} set to ${VALUE}."
    +			echo "${VALUE}"
    +			return
    +
    +		else
    +			ERROR_MSG="${VALUE}\n\n"
    +		fi
    +	done
    +}
    +
    +####
    +# Try to 't automatically determine the latitude and longitude.
    +# If we can't prompt for them.
    +function get_lat_long()
    +{
    +	# Global: SETTINGS_FILE
    +	local MSG  LATITUDE  LAT  LONGITUDE  LON  RAW_LOCATION  MY_LOCATION_PARTS  ERR  X
    +
    +	if [[ ! -f ${SETTINGS_FILE} ]]; then
    +		display_msg --log error "INTERNAL ERROR: '${SETTINGS_FILE}' not found!"
    +		return 1
    +	fi
    +
    +	LAT=""
    +	LON=""
    +	# Check we have an internect connection
    +	if wget -q --spider "http://www.google.com" 2>/dev/null ; then
    +		# Use ipinfo.io to get the user's lat and lon from their IP.
    +		RAW_LOCATION="$( curl -s ipinfo.io/loc 2>/dev/null )"
    +		# If we got a json response then it's an error.
    +		# If "jq" fails we did NOT get json response.
    +		if ERR="$( jq -e . 2>&1 <<<"${RAW_LOCATION}" )"; then
    +			MSG="Got error response trying to get latitude and longitude from IP address:"
    +			MSG+="\n${ERR}"
    +			display_msg --logonly info "${MSG}"
    +		else
    +			# Lat and Lon are returned as a comma separated string i.e. 52.1234,0.3123
    +			# Setting an array variable needs the items to be space-separated.
    +			# shellcheck disable=SC2206
    +			MY_LOCATION_PARTS=( ${RAW_LOCATION/,/ } )
    +			if [[ ${#MY_LOCATION_PARTS[@]} -eq 2 ]]; then
    +
    +				LAT="${MY_LOCATION_PARTS[0]}"
    +				LON="${MY_LOCATION_PARTS[1]}"
    +
    +				if [[ $( echo "${LAT} > 0" | bc ) -eq 1 ]] ; then
    +					LAT="${LAT}N"
    +				else
    +					LAT="${LAT//-/}S"
    +				fi
    +
    +				if [[ $( echo "$LON > 0" | bc ) -eq 1 ]] ; then
    +					LON="${LON}E"
    +				else
    +					LON="${LON//-/}W"
    +				fi
    +			else
    +				display_msg --logonly info "'${RAW_LOCATION}' did not have two fields."
    +			fi
    +		fi
    +	else
    +		display_msg --logonly info "No internet connection detected; skipping geolocation."
    +	fi
    +
    +	if [[ -z ${LAT} ]]; then
    +		MSG="Prompting for Latitude and Longitude."
    +		X="Enter"
    +	else
    +		MSG="Verifying pre-determined Latitude and Longitude."
    +		X="Verify"
    +	fi
    +	display_msg --log progress "${MSG}"
    +	MSG="${X} your Latitude."
    +	MSG+="\nIt can either have a plus or minus sign (e.g., -20.1)"
    +	MSG+="\nor N or S (e.g., 20.1N)"
    +	if [[ -n ${LAT} ]]; then
    +		MSG+="\n\n*** Your APPROXIMATE Latitude using your IP Address is below. ***"
    +	fi
    +	LATITUDE="$( prompt_for_lat_long "${MSG}" "latitude" "Latitude" "${LAT}" )"
    +
    +	MSG="${X} your Longitude."
    +	MSG+="\nIt can either have a plus or minus sign (e.g., -20.1)"
    +	MSG+="\nor E or W (e.g., 20.1W)"
    +	if [[ -n ${LON} ]]; then
    +		MSG+="\n\n*** Your APPROXIMATE Longitude using your IP Address is below. ***"
    +	fi
    +	LONGITUDE="$( prompt_for_lat_long "${MSG}" "longitude" "Longitude" "${LON}" )"
    +
    +	if [[ -z ${LATITUDE} || -z ${LONGITUDE} ]]; then
    +		MSG="Latitude and Longitude need to be set in the WebUI before Allsky can start."
    +		display_msg --log warning "${MSG}"
    +		return 1
    +	fi
    +	return 0
    +}
    +
    +
    +####
    +# Return the amount of RAM in GB.
    +function get_RAM()
    +{
    +	# Input example: total_mem=4096
    +	# Pi's have either 0.5 GB or an integer number of GB.
    +	sudo vcgencmd get_config total_mem | gawk --field-separator "=" '
    +		{
    +			if ($2 < 1024)
    +				printf("%.1f", $2 / 1024);
    +			else
    +				printf("%d", $2 / 1024);
    +			exit 0;
    +		}'
    +
    +}
    +
    +
    +####
    +# Return the "computer" - the Pi model and amount of memory in GB
    +function get_computer()
    +{
    +	# The file has a NULL at the end so to avoid a bash warning, ignore it.
    +	local MODEL="$( tr --delete '\0' < /sys/firmware/devicetree/base/model |
    +			sed 's/Raspberry Pi/RPi/')"
    +	local GB="$( get_RAM )"
    +	echo "${MODEL}, ${GB} GB"
    +}
    +
    +
    +####
    +# Get a value from the php ini file, using php rather than parsing the ini 
    +# files directly. This does assume that both the cli and cgi settings files
    +# work in the same way.
    +#
    +function get_php_setting()
    +{
    +    local SETTING="${1}"
    +    php -r "echo ini_get('${SETTING}');"
    +}
    +
    +
    +####
    +# Get the checksum of all Website files, not including the ones the user creates or updates.
    +function get_website_checksums()
    +{
    +	(
    +		cd "${ALLSKY_WEBSITE}"		|| exit 1
    +
    +		# Add important image files.
    +		echo loading.jpg
    +		echo allsky-logo.png
    +		echo NoThumbnail.png
    +		echo allsky-favicon.png
    +
    +		# Get all non-image files except for the ones the user creates/updates.
    +		find . -type f '!' '(' -name '*.jpg' -or -name '*.png' -or -name '*.mp4' ')' |
    +			sed 's;^./;;' |
    +			grep -E -v "myFiles/|${ALLSKY_WEBSITE_CONFIGURATION_NAME}|$( basename "${CHECKSUM_FILE}" )"
    +	) | "${ALLSKY_UTILITIES}/getChecksum.php"
    +}
    +
    +
    +####
    +# Update the specified file with the specified new value.
    +# ${V_} must be a legal shell variable name.
    +# Use V_ and VAL_ in case the caller uses V or VAL
    +doV()
    +{
    +	local oldV="${1}"		# Optional name of old variable; if "" then use ${V_}.
    +	local V_="${2}"			# name of the variable that holds the new value
    +	local VAL_="${!V_}"		# value of the variable
    +	local jV="${3}"			# new json variable name
    +	local TYPE="${4}"
    +	local FILE="${5}"
    +
    +	[[ -z ${oldV} ]] && oldV="${V_}"
    +
    +	if [[ ${TYPE} == "boolean" ]]; then
    +		# Some booleans used "true/false" and some used "1/0".
    +		if [[ ${VAL_} == "true" || ${VAL_} == "1" ]]; then
    +			VAL_="true"
    +		else
    +			VAL_="false"
    +		fi
    +	elif [[ ${TYPE} == "number" && -z ${VAL_} ]]; then
    +		VAL_=0		# give it a default
    +	fi
    +
    +	local ERR  MSG
    +	if ERR="$( update_json_file ".${jV}" "${VAL_}" "${FILE}" "${TYPE}" 2>&1 )" ; then
    +		if [[ ${oldV} == "${jV}" ]]; then
    +			oldV=""
    +		else
    +			oldV+=": "
    +		fi
    +		MSG="${SPACE}${oldV}${jV} = ${VAL_}"
    +		[[ -n ${oldV} ]] && MSG+=", TYPE=${TYPE}"
    +		display_msg --logonly info "${MSG}"
    +	else
    +		# update_json_file() returns error message.
    +		display_msg --log warning "${ERR}"
    +	fi
    +}
    +
    +
    +####
    +# Check for settings in the options file that aren't in the settings file.
    +# These are new settings.
    +# This has to come after the new settings and options files are created.
    +function add_new_settings()
    +{
    +	local SETTINGS="${1}"
    +	local OPTIONS="${2}"
    +	local FROM_INSTALL="${3}"
    +
    +	if [[ ${FROM_INSTALL} == "false" ]]; then
    +		function display_msg() { return; }
    +	fi
    +
    +	display_msg --logonly info "Checking for new settings in options file."
    +
    +	local TAB="$( echo -e '\t' )"
    +	local NEW="$( "${ALLSKY_SCRIPTS}/convertJSON.php" \
    +		--options-only \
    +		--settings-file "${SETTINGS}" \
    +		--options-file "${OPTIONS}" \
    +		--delimiter "${TAB}" \
    +		2>&1 )"
    +	if [[ $? -ne 0 ]]; then
    +		local M="Unable to get new settings"
    +		MSG="${M}: $( < "${NEW}" )"
    +		if [[ ${FROM_INSTALL} == "true" ]]; then
    +			display_msg --log error "${MSG}"
    +			exit_installation 1 "${STATUS_ERROR}" "${M}."
    +		else
    +			echo "ERROR: ${MSG}" >&2
    +			return 1
    +		fi
    +	fi
    +	if [[ -z ${NEW} ]]; then
    +		display_msg --logonly info "  >> No new settings in options file."
    +		return 0
    +	fi
    +
    +	IFS="${TAB}"
    +	echo -e "${NEW}" | while read -r SETTING VALUE TYPE
    +		do
    +			[[ -z ${SETTING} ]] && continue
    +
    +			# "read" doesn't work with empty fields.
    +			if [[ -z ${TYPE} ]]; then
    +				TYPE="${VALUE}"
    +				VALUE=""
    +			fi
    +			doV "NEW" "VALUE" "${SETTING}" "${TYPE}" "${SETTINGS}"
    +		done
    +
    +	return 0
    +}
    diff --git a/scripts/makeChanges.sh b/scripts/makeChanges.sh
    index a6761217b..9b6265d3b 100755
    --- a/scripts/makeChanges.sh
    +++ b/scripts/makeChanges.sh
    @@ -1,32 +1,27 @@
     #!/bin/bash
    +# shellcheck disable=SC2154		# referenced but not assigned - from convertJSON.php
     
     # Allow this script to be executed manually, which requires several variables to be set.
    -[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$(realpath "$(dirname "${BASH_ARGV0}")/..")"
    -ME="$(basename "${BASH_ARGV0}")"
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
    +ME="$( basename "${BASH_ARGV0}" )"
     
     #shellcheck source-path=.
    -source "${ALLSKY_HOME}/variables.sh"			|| exit "${ALLSKY_ERROR_STOP}"
    +source "${ALLSKY_HOME}/variables.sh"					|| exit "${EXIT_ERROR_STOP}"
     #shellcheck source-path=scripts
    -source "${ALLSKY_SCRIPTS}/functions.sh"			|| exit "${ALLSKY_ERROR_STOP}"
    -
    -# This script may be called during installation BEFORE there is a settings file.
    -# config.sh looks for the file and produces an error if it doesn't exist,
    -# so only include these two files if there IS a settings file.
    -if [[ -f ${SETTINGS_FILE} ]]; then
    -	#shellcheck disable=SC1091		# file doesn't exist in GitHub
    -	source "${ALLSKY_CONFIG}/config.sh"			|| exit "${ALLSKY_ERROR_STOP}"
    -	#shellcheck disable=SC1091		# file doesn't exist in GitHub
    -	source "${ALLSKY_CONFIG}/ftp-settings.sh"	|| exit "${ALLSKY_ERROR_STOP}"
    -fi
    +source "${ALLSKY_SCRIPTS}/functions.sh"					|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/installUpgradeFunctions.sh"	|| exit "${EXIT_ERROR_STOP}"
     
     function usage_and_exit()
     {
    -	echo -en "${wERROR}"
    -	echo     "Usage: ${ME} [--debug] [--optionsOnly] [--cameraTypeOnly] [--restarting]"
    -	echo -en "\tkey label old_value new_value [...]"
    -	echo -e  "${wNC}"
    -	echo "There must be a multiple of 4 key/label/old_value/new_value arguments"
    -	echo "unless the --optionsOnly argument is given."
    +	{
    +		echo -en "${wERROR}"
    +		echo     "Usage: ${ME} [--debug] [--optionsOnly] [--cameraTypeOnly] [--fromInstall] [--addNewSettings]"
    +		echo -en "\tkey label old_value new_value [...]"
    +		echo -e  "${wNC}"
    +		echo "There must be a multiple of 4 key/label/old_value/new_value arguments"
    +		echo "unless the --optionsOnly argument is given."
    +	} >&2
     	exit "${1}"
     }
     
    @@ -36,13 +31,14 @@ DEBUG="false"
     DEBUG_ARG=""
     HELP="false"
     OPTIONS_FILE_ONLY="false"
    -RESTARTING="false"			# Will the caller restart Allsky?
    -CAMERA_TYPE_ONLY="false"	# Only update the cameraType ?
    +CAMERA_TYPE_ONLY="false"	# Only update the cameratype?
    +FROM_INSTALL="false"		# Called from install.sh ?
    +ADD_NEW_SETTINGS="false"
     FORCE=""					# Passed to createAllskyOptions.php
     
     while [[ $# -gt 0 ]]; do
    -	ARG="${1,,}"					# convert to lowercase
    -	case "${ARG}" in
    +	ARG="${1}"
    +	case "${ARG,,}" in
     		--debug)
     			DEBUG="true"
     			DEBUG_ARG="${ARG}"		# So we can pass to other scripts
    @@ -57,12 +53,15 @@ while [[ $# -gt 0 ]]; do
     		--cameratypeonly)
     			CAMERA_TYPE_ONLY="true"
     			;;
    +		--frominstall)
    +			FROM_INSTALL="true"
    +			;;
    +		--addnewsettings)
    +			ADD_NEW_SETTINGS="true"
    +			;;
     		--force)
     			FORCE="${ARG}"
     			;;
    -		--restarting)
    -			RESTARTING="true"
    -			;;
     		-*)
     			echo -e "${wERROR}ERROR: Unknown argument: '${ARG}'${wNC}"
     			OK="false"
    @@ -74,6 +73,21 @@ while [[ $# -gt 0 ]]; do
     	shift
     done
     
    +if [[ ${ON_TTY} == "false" ]]; then		# called from WebUI.
    +	# The WebUI will display our output in an
    +	# appropriate style if ERROR: or WARNING: is in the message, so
    +	# don't provide our own format.
    +	ERROR_PREFIX=""
    +	wERROR=""
    +	wDEBUG="DEBUG: "
    +	wWARNING=""
    +	wNC=""
    +	BR="<br>"
    +else
    +	ERROR_PREFIX="${ME}: "
    +	BR="\n"
    +fi
    +
     [[ ${HELP} == "true" ]] && usage_and_exit 0
     [[ ${OK} == "false" ]] && usage_and_exit 1
     if [[ ${OPTIONS_FILE_ONLY} == "false" ]]; then
    @@ -81,36 +95,18 @@ if [[ ${OPTIONS_FILE_ONLY} == "false" ]]; then
     	[[ $(($# % 4)) -ne 0 ]] && usage_and_exit 2
     fi
     
    -if [[ ${ON_TTY} -eq 0 ]]; then		# called from WebUI.
    -	ERROR_PREFIX=""
    -else
    -	ERROR_PREFIX="${ME}: "
    -fi
    -
    -# This output may go to a web page, so use "w" colors.
    -# shell check doesn't realize there were set in variables.sh
    -wOK="${wOK}"
    -wWARNING="${wWARNING}"
    -wERROR="${wERROR}"
    -wDEBUG="${wDEBUG}"
    -wBOLD="${wBOLD}"
    -wNBOLD="${wNBOLD}"
    -wNC="${wNC}"
    -
    -# Does the change need Allsky to be restarted in order to take affect?
    -NEEDS_RESTART="false"
    -
     RUN_POSTTOMAP="false"
     POSTTOMAP_ACTION=""
     WEBSITE_CONFIG=()
     WEB_CONFIG_FILE=""
     HAS_WEBSITE_RET=""
     WEBSITES=""		# local, remote, both, none
    -SHOW_POSTDATA_MESSAGE="true"
    -TWILIGHT_DATA_CHANGED="false"
    -CAMERA_TYPE_CHANGED="false"
     GOT_WARNING="false"
     SHOW_ON_MAP=""
    +CHECK_REMOTE_WEBSITE_ACCESS="false"
    +CHECK_REMOTE_SERVER_ACCESS="false"
    +USE_REMOTE_WEBSITE=""
    +USE_REMOTE_SERVER=""
     
     # Several of the fields are in the Allsky Website configuration file,
     # so check if the IS a file before trying to update it.
    @@ -120,7 +116,7 @@ function check_website()
     {
     	[[ -n ${HAS_WEBSITE_RET} ]] && return "${HAS_WEBSITE_RET}"		# already checked
     
    -	WEBSITES="$(whatWebsites)"
    +	WEBSITES="$( whatWebsites )"
     	if [[ ${WEBSITES} == "local" || ${WEBSITES} == "both" ]]; then
     		WEB_CONFIG_FILE="${ALLSKY_WEBSITE_CONFIGURATION_FILE}"
     		HAS_WEBSITE_RET=0
    @@ -133,7 +129,26 @@ function check_website()
     	fi
     	return "${HAS_WEBSITE_RET}"
     }
    -check_website		# invoke to set variables
    +
    +# Get all settings at once rather than individually via settings().
    +if [[ -f ${SETTINGS_FILE} ]]; then
    +	# check_website requires the settings file to exist.
    +	# If it doesn't we are likely called from the install script before the file is created.
    +	check_website		# invoke to set variables
    +
    +	X="$( "${ALLSKY_SCRIPTS}/convertJSON.php" --prefix S_ --shell )"
    +	if [[ $? -ne 0 ]]; then
    +		echo "${X}"
    +		exit 1
    +	fi
    +	eval "${X}"
    +fi
    +if [[ -f ${CC_FILE} ]]; then
    +	# "convertJSON.php" won't work with the CC_FILE since it has arrays.
    +	C_sensorWidth="$( settings ".sensorWidth" "${CC_FILE}" )"
    +	C_sensorHeight="$( settings ".sensorHeight" "${CC_FILE}" )"
    +fi
    +
     
     # Make sure RAW16 files have a .png extension.
     function check_filename_type()
    @@ -154,6 +169,11 @@ function check_filename_type()
     }
     
     CAMERA_NUMBER=""
    +CAMERA_NUMBER_ARG=""
    +CAMERA_MODEL=""
    +CAMERA_MODEL_ARG=""
    +
    +NUM_CHANGED=0
     
     while [[ $# -gt 0 ]]
     do
    @@ -161,64 +181,98 @@ do
     	LABEL="${2}"
     	OLD_VALUE="${3}"
     	NEW_VALUE="${4}"
    +
     	if [[ ${DEBUG} == "true" ]]; then
     		MSG="${KEY}: Old=[${OLD_VALUE}], New=[${NEW_VALUE}]"
     		echo -e "${wDEBUG}${ME}: ${MSG}${wNC}"
    -		if [[ ${ON_TTY} -eq 0 ]]; then		# called from WebUI.
    +		if [[ ${ON_TTY} == "false" ]]; then		# called from WebUI.
     			echo -e "<script>console.log('${MSG}');</script>"
     		fi
     	fi
     
    -	# Unfortunately, the Allsky configuration file was already updated,
    -	# so if we find a bad entry, e.g., a file doesn't exist, all we can do is warn the user.
    -	
    -	K="${KEY,,}"		# convert to lowercase
    -	case "${K}" in
    -
    -		cameranumber | cameratype)
    -			if [[ ${K} == "cameranumber" ]]; then
    -				NEW_CAMERA_NUMBER="${NEW_VALUE}"
    -				CAMERA_NUMBER=" -cameraNumber ${NEW_CAMERA_NUMBER}"
    -				# Set NEW_VALUE to the current Camera Type
    -				NEW_VALUE="$( settings .cameraType )"
    -
    -				MSG="Re-creating files for cameraType ${NEW_VALUE}, cameraNumber ${NEW_CAMERA_NUMBER}"
    -				if [[ ${ON_TTY} -eq 0 ]]; then		# called from WebUI.
    -					echo -e "<script>console.log('${MSG}');</script>"
    -				elif [[ ${DEBUG} == "true" ]]; then
    -					echo -e "${wDEBUG}${MSG}${wNC}"
    +	KEY="${KEY,,}"		# convert to lowercase
    +	KEY="${KEY/#_/}"	# Remove any leading "_"
    +
    +	# Don't skip if it's cameratype since that indicates we need to refresh.
    +	if [[ ${KEY} != "cameratype" && ${OLD_VALUE} == "${NEW_VALUE}" ]]; then
    +		if [[ ${DEBUG} == "true" ]]; then
    +			echo -e "    ${wDEBUG}Skipping - old and new are equal${wNC}"
    +		fi
    +		shift 4
    +		continue
    +	fi
    +
    +	# The Allsky configuration file was already updated.
    +	# If we find a bad entry, e.g., a file doesn't exist, all we can do is warn the user.
    +
    +	((NUM_CHANGED++))
    +	case "${KEY}" in
    +
    +		# When called from the installer we get cameranumber, cameramodel, and cameratype.
    +		# This is the only time cameranumber should be used since it could change if,
    +		# for example, a user removes a camera.
    +		# When called from the WebUI we only get what the user changed which is
    +		# either cameramodel OR cameratype.
    +		"cameranumber")
    +			CAMERA_NUMBER="${NEW_VALUE}"
    +			CAMERA_NUMBER_ARG=" -cameranumber ${CAMERA_NUMBER}"
    +			;;
    +
    +		"cameramodel" | "cameratype")
    +			if [[ ${KEY} == "cameramodel" ]]; then
    +				CAMERA_MODEL="${NEW_VALUE}"
    +
    +				if [[ ${FROM_INSTALL} == "true" ]]; then
    +					# When called during installation the camera model is
    +					# passed in, then the camera type.
    +					shift 4
    +					continue
     				fi
    -			fi
     
    -			if [[ ! -e "${ALLSKY_BIN}/capture_${NEW_VALUE}" ]]; then
    -				MSG="Unknown Camera Type: '${NEW_VALUE}'."
    -				echo -e "${wERROR}${ERROR_PREFIX}ERROR: ${MSG}${wNC}"
    -				exit "${EXIT_NO_CAMERA}"
    +				# If only the CAMERA_MODEL changed, it's the same CAMERA_TYPE.
    +				CAMERA_TYPE="$( settings ".cameratype" )"
    +
    +			else
    +				CAMERA_TYPE="${NEW_VALUE}"
    +				if [[ ! -e "${ALLSKY_BIN}/capture_${CAMERA_TYPE}" ]]; then
    +					MSG="Unknown Camera Type: '${CAMERA_TYPE}'."
    +					echo -e "${wERROR}${ERROR_PREFIX}ERROR: ${MSG}${wNC}"
    +					exit "${EXIT_NO_CAMERA}"
    +				fi
     			fi
     
     			# This requires Allsky to be stopped so we don't
     			# try to call the capture program while it's already running.
    -			sudo systemctl stop allsky 2> /dev/null
    +			stop_Allsky
     
     			if [[ ${OPTIONS_FILE_ONLY} == "false" ]]; then
     
     				# If we can't set the new camera type, it's a major problem so exit right away.
    -				# NOTE: when we're changing cameraType we're not changing anything else.
    +				# NOTE: when we're changing cameratype we're not changing anything else.
     
     				# The software for RPi cameras needs to know what command is being used to
     				# capture the images.
     				# determineCommandToUse either retuns the command with exit code 0,
     				# or an error message with non-zero exit code.
    -				if [[ ${NEW_VALUE} == "RPi" ]]; then
    -					C="$( determineCommandToUse "false" "" )"
    +				if [[ ${CAMERA_TYPE} == "RPi" ]]; then
    +					RPi_COMMAND_TO_USE="$( determineCommandToUse "false" "" "false" 2>&1 )"
     					RET=$?
     					if [[ ${RET} -ne 0 ]] ; then
    -						echo -e "${wERROR}${ERROR_PREFIX}ERROR: ${C}.${wNC}"
    +						echo -e "${wERROR}${ERROR_PREFIX}ERROR: ${RPi_COMMAND_TO_USE}.${wNC}"
     						exit "${RET}"
     					fi
    -					C=" -cmd ${C}"
    +
    +					if [[ ${FROM_INSTALL} == "false" ]]; then
    +						# Installation routine already did this,
    +						# otherwise do it again in case the list of cameras changed.
    +
    +						# "false" means don't ignore errors (i.e., exit on error).
    +						get_connected_cameras_info "false" > "${CONNECTED_CAMERAS_INFO}"
    +					fi
    +
    +					OTHER_ARGS="-cmd ${RPi_COMMAND_TO_USE}"
     				else
    -					C=""
    +					OTHER_ARGS=""
     				fi
     
     				CC_FILE_OLD="${CC_FILE}-OLD"
    @@ -232,13 +286,40 @@ do
     				# Create the camera capabilities file for the new camera type.
     				# Use Debug Level 3 to give the user more info on error.
     
    -				CMD="capture_${NEW_VALUE}${C}${CAMERA_NUMBER}"
    +				if [[ -n ${CAMERA_NUMBER} ]]; then
    +					MSG="Re-creating files for cameratype ${CAMERA_TYPE},"
    +					MSG+=" cameranumber ${CAMERA_NUMBER}"
    +					if [[ ${ON_TTY} == "false" ]]; then		# called from WebUI.
    +						echo -e "<script>console.log('${MSG}');</script>"
    +					elif [[ ${DEBUG} == "true" ]]; then
    +						echo -e "${wDEBUG}${MSG}${wNC}"
    +					fi
    +				fi
    +
    +				# Can't quote items in ${CMD} or else they get double quoted when executed.
    +				CMD="capture_${CAMERA_TYPE}"
    +				OTHER_ARGS+=" -debuglevel 3 ${CAMERA_NUMBER_ARG}"
    +				if [[ -n ${CAMERA_MODEL} ]]; then
    +					CAMERA_MODEL_ARG="-cameramodel '${CAMERA_MODEL}'"
    +				else
    +					CAMERA_MODEL_ARG=""
    +				fi
     				if [[ ${DEBUG} == "true" ]]; then
    -					echo -e "${wDEBUG}Calling ${CMD} -cc_file '${CC_FILE}'${wNC}"
    +					echo -en "${wDEBUG}"
    +					echo "Calling: ${CMD} ${OTHER_ARGS} ${CAMERA_MODEL_ARG} -cc_file '${CC_FILE}'"
    +					echo -e "${wNC}"
     				fi
     
    -				# shellcheck disable=SC2086
    -				R="$( "${ALLSKY_BIN}"/${CMD} -debuglevel 3 -cc_file "${CC_FILE}" 2>&1 )"
    +				# CAMERA_MODEL may have spaces in it so can't put in quotes in
    +				# ${OTHER_ARGS} (at least I don't know how).
    +				if [[ -n ${CAMERA_MODEL} ]]; then
    +					# shellcheck disable=SC2086
    +					R="$( "${ALLSKY_BIN}/${CMD}" ${OTHER_ARGS} -cc_file "${CC_FILE}" \
    +						-cameramodel "${CAMERA_MODEL}" 2>&1 )"
    +				else
    +					# shellcheck disable=SC2086
    +					R="$( "${ALLSKY_BIN}"/${CMD} ${OTHER_ARGS} -cc_file "${CC_FILE}" 2>&1 )"
    +				fi
     				RET=$?
     				if [[ ${RET} -ne 0 || ! -f ${CC_FILE} ]]; then
     					# Restore prior cc file if there was one.
    @@ -246,73 +327,45 @@ do
     
     					# Invoker displays error message on EXIT_NO_CAMERA.
     					if [[ ${RET} -ne "${EXIT_NO_CAMERA}" ]]; then
    -						echo -en "\n${wERROR}ERROR: "
    +						echo -en "${BR}${wERROR}ERROR: "
     						if [[ ${RET} -eq 139 ]]; then
     							echo -en "Segmentation fault in ${CMD}"
     						else
    -							echo -en "${R}\nUnable to create cc file '${CC_FILE}'."
    +							echo -en "${R}${BR}Unable to create cc file '${CC_FILE}'."
     						fi
     						echo -e "${wNC}"
     					fi
    -					exit ${RET}		# the actual exit code is important
    +					exit "${RET}"		# the actual exit code is important
     				fi
     				[[ -n ${R} ]] && echo -e "${R}"
     
     				# Create a link to a file that contains the camera type and model in the name.
    -				CAMERA_TYPE="${NEW_VALUE}"		# already know it
    -				CAMERA_MODEL="$( settings .cameraModel "${CC_FILE}" )"
    +
     				if [[ -z ${CAMERA_MODEL} ]]; then
    -					echo -e "${wERROR}ERROR: 'cameraModel' not found in ${CC_FILE}.${wNC}"
    -					[[ -f ${CC_FILE_OLD} ]] && mv "${CC_FILE_OLD}" "${CC_FILE}"
    -					exit 1
    +					SETTING_NAME="cameraModel"		# Name is Upper case in CC file
    +					CAMERA_MODEL="$( settings ".${SETTING_NAME}" "${CC_FILE}" )"
    +					if [[ -z ${CAMERA_MODEL} ]]; then
    +						echo -e "${wERROR}ERROR: '${SETTING_NAME}' not found in ${CC_FILE}.${wNC}"
    +						[[ -f ${CC_FILE_OLD} ]] && mv "${CC_FILE_OLD}" "${CC_FILE}"
    +						exit 1
    +					fi
     				fi
     
    -				# ${CC_FILE} is a generic name defined in config.sh.
    +				# ${CC_FILE} is a generic name defined in variables.sh.
     				# ${SPECIFIC_NAME} is specific to the camera type/model.
     				# It isn't really needed except debugging.
    -				CC="$(basename "${CC_FILE}")"
    +				CC="$( basename "${CC_FILE}" )"
     				CC_EXT="${CC##*.}"			# after "."
     				CC_NAME="${CC%.*}"			# before "."
    -				SPECIFIC_NAME="${ALLSKY_CONFIG}/${CC_NAME}_${CAMERA_TYPE}_${CAMERA_MODEL}.${CC_EXT}"
    +				SPECIFIC_NAME="${ALLSKY_CONFIG}/"
    +				SPECIFIC_NAME+="${CC_NAME}_${CAMERA_TYPE}_${CAMERA_MODEL// /_}.${CC_EXT}"
     
     				# Any old and new camera capabilities file should be the same unless Allsky
     				# adds or changes capabilities, so delete the old one just in case.
     				ln --force "${CC_FILE}" "${SPECIFIC_NAME}"
     
    -				if ! sed -i -e "s/^CAMERA_TYPE=.*$/CAMERA_TYPE=\"${NEW_VALUE}\"/" "${ALLSKY_CONFIG}/config.sh"; then
    -					echo -e "${wERROR}ERROR updating ${wBOLD}${LABEL}${wNBOLD}.${wNC}"
    -					[[ -f ${CC_FILE_OLD} ]] && mv "${CC_FILE_OLD}" "${CC_FILE}"
    -					exit 1
    -				fi
    -
     				# The old file is no longer needed.
     				rm -f "${CC_FILE_OLD}"
    -
    -				# Change other things that vary depending on CAMERA_TYPE and CAMERA_MODEL.
    -				if [[ ${OLD_VALUE} != "${NEW_VALUE}" ]]; then
    -					# Move the current overlay.json to the old camera-specific name,
    -					# then copy the new camera-specific named file to overlay.json.
    -					O="${ALLSKY_OVERLAY}/config/overlay.json"
    -					if [[ -n ${OLD_VALUE} && -f ${O} ]]; then
    -						if [[ ${DEBUG} == "true" ]]; then
    -							echo -e "${wDEBUG}Moving overlay.json to overlay-${OLD_VALUE}.json${wNC}"
    -						fi
    -						mv -f "${O}" "${ALLSKY_OVERLAY}/config/overlay-${OLD_VALUE}.json"
    -					fi
    -
    -					# When we're called during Allsky installation,
    -					# the Camera-Specific Overlay (CSO) file may not exist yet.
    -					CSO="${ALLSKY_OVERLAY}/config/overlay-${NEW_VALUE}.json"
    -					if [[ -f ${CSO} ]]; then
    -						if [[ ${DEBUG} == "true" ]]; then
    -							echo -e "${wDEBUG}Copying overlay-${NEW_VALUE}.json to overlay.json${wNC}"
    -						fi
    -						# Need to preserve permissions so use "-a".
    -						cp -a "${CSO}" "${O}"
    -					elif [[ ${DEBUG} == "true" ]]; then
    -						echo -e "${wDEBUG}'${CSO}' doesn't exist yet - ignoring.${wNC}"
    -					fi
    -				fi
     			fi
     
     			# createAllskyOptions.php will use the cc file and the options template file
    @@ -323,48 +376,123 @@ do
     			# If there is no existing camera-specific file, i.e., this camera is new
     			# to Allsky, it will create a default settings file using the generic
     			# values from the prior settings file if it exists.
    +			if [[ -f ${SETTINGS_FILE} ]]; then
    +				# Prior settings file exists so save the old TYPE and MODEL
    +				OLD_TYPE="${S_cameratype}"
    +				OLD_MODEL="${S_cameramodel}"
    +			else
    +				OLD_TYPE=""
    +				OLD_MODEL=""
    +			fi
     
     			if [[ ${DEBUG} == "true" ]]; then
     				# shellcheck disable=SC2086
     				echo -e "${wDEBUG}Calling:" \
    -					"${ALLSKY_WEBUI}/includes/createAllskyOptions.php" \
    +					"${ALLSKY_SCRIPTS}/createAllskyOptions.php" \
     					${FORCE} ${DEBUG_ARG} \
    -					"\n\t--cc_file ${CC_FILE}" \
    -					"\n\t--options_file ${OPTIONS_FILE}" \
    -					"\n\t--settings_file ${SETTINGS_FILE}" \
    +					"\n\t--cc-file ${CC_FILE}" \
    +					"\n\t--options-file ${OPTIONS_FILE}" \
    +					"\n\t--settings-file ${SETTINGS_FILE}" \
     					"${wNC}"
     			fi
     			# shellcheck disable=SC2086
    -			R="$("${ALLSKY_WEBUI}/includes/createAllskyOptions.php" \
    +			R="$( "${ALLSKY_SCRIPTS}/createAllskyOptions.php" \
     				${FORCE} ${DEBUG_ARG} \
    -				--cc_file "${CC_FILE}" \
    -				--options_file "${OPTIONS_FILE}" \
    -				--settings_file "${SETTINGS_FILE}" \
    -				2>&1)"
    +				--cc-file "${CC_FILE}" \
    +				--options-file "${OPTIONS_FILE}" \
    +				--settings-file "${SETTINGS_FILE}" \
    +				2>&1 )"
     			RET=$?
     
    +			if [[ -f ${SETTINGS_FILE} ]]; then
    +				# Make sure the web server can update it.
    +				chmod 664 "${SETTINGS_FILE}" && sudo chgrp "${WEBSERVER_GROUP}" "${SETTINGS_FILE}"
    +			fi
    +
     			if [[ ${RET} -ne 0 ]]; then
    -				echo -n -e "${wERROR}ERROR: Unable to create '${OPTIONS_FILE}'"
    +				echo -en "${wERROR}ERROR: Unable to create '${OPTIONS_FILE}'"
     				if [[ ${OPTIONS_FILE_ONLY} == "true" ]]; then
    -					echo -e "file."
    +					echo -n " file"
     				else
    -					echo -e " and '${SETTINGS_FILE}' files."
    +					echo -n " and '${SETTINGS_FILE}' files"
     				fi
    -				echo -e "${wNC}, RET=${RET}:${R}"
    +				echo -e "${wNC}, RET=${RET}: ${R}"
     				exit 1
     			fi
     			[[ ${DEBUG} == "true" && -n ${R} ]] && echo -e "${wDEBUG}${R}${wNC}"
     
    -			OK="true"
    +			ERR=""
     			if [[ ! -f ${OPTIONS_FILE} ]]; then
    -				echo -e "${wERROR}${ERROR_PREFIX}ERROR Options file ${OPTIONS_FILE} not created.${wNC}"
    -				OK="false"
    +				ERR+="${BR}ERROR Options file ${OPTIONS_FILE} not created."
     			fi
     			if [[ ! -f ${SETTINGS_FILE} && ${OPTIONS_FILE_ONLY} == "false" ]]; then
    -				echo -e "${wERROR}${ERROR_PREFIX}ERROR Settings file ${SETTINGS_FILE} not created.${wNC}"
    -				OK="false"
    +				ERR+="${BR}ERROR Settings file ${SETTINGS_FILE} not created."
    +			fi
    +			if [[ -n ${ERR} ]]; then
    +				echo -e "${wERROR}${ERROR_PREFIX}${ERR}${wNC}"
    +				exit 2
    +			fi
    +
    +			# See if a camera-specific settings file was created.
    +			# If the latitude isn't set assume it's a new file.
    +			if [[ -n ${OLD_TYPE} && -n ${OLD_MODEL} &&
    +					-z "$( settings ".latitude" "${SETTINGS_FILE}" )" ]]; then
    +
    +				# We assume the user wants the non-camera specific settings below
    +				# for this camera to be the same as the prior camera.
    +
    +				if [[ ${DEBUG} == "true" ]]; then
    +					MSG="Updating user-defined settings in new settings file."
    +					echo -e "${wDEBUG}${MSG}${wNC}"
    +				fi
    +
    +				# First determine the name of the prior camera-specific settings file.
    +				NAME="$( basename "${SETTINGS_FILE}" )"
    +				S_NAME="${NAME%.*}"
    +				S_EXT="${NAME##*.}"
    +				OLD_SETTINGS_FILE="${ALLSKY_CONFIG}/${S_NAME}_${OLD_TYPE}_${OLD_MODEL// /_}.${S_EXT}"
    +				"${ALLSKY_SCRIPTS}/convertJSON.php" --carryforward |
    +				while read -r SETTING TYPE
    +				do
    +					# Some carried-forward settings may not be in the old settings file,
    +					# so check for "null".
    +					X="$( settings --null ".${SETTING}" "${OLD_SETTINGS_FILE}" )"
    +					[[ ${X} == "null" ]] && continue
    +
    +					update_json_file ".${SETTING}" "${X}" "${SETTINGS_FILE}" "${TYPE}" ||
    +						echo "WARNING: Unable to update ${SETTING} of type ${TYPE}" >&2
    +				done
    +			fi
    +
    +			FULL_OVERLAY_NAME="overlay-${CAMERA_TYPE}_${CAMERA_MODEL// /_}"
    +			FULL_OVERLAY_NAME+="-${C_sensorWidth}x${C_sensorHeight}-both.json"
    +			OVERLAY_PATH="${ALLSKY_REPO}/overlay/config/${FULL_OVERLAY_NAME}"
    +			if [[ -f ${OVERLAY_PATH} ]]; then
    +				OVERLAY_NAME=${FULL_OVERLAY_NAME}
    +			else
    +				OVERLAY_NAME="overlay-${CAMERA_TYPE}.json"
    +			fi
    +			# Set to defaults since there are no prior files.
    +			for s in daytimeoverlay nighttimeoverlay
    +			do
    +				update_json_file ".${s}" "${OVERLAY_NAME}" "${SETTINGS_FILE}" "text"
    +			done
    +			COMPUTER="$( get_computer )"
    +			update_json_file ".computer" "${COMPUTER}" "${SETTINGS_FILE}" "text"
    +			update_json_file ".camera" "${CAMERA_TYPE} ${CAMERA_MODEL}" "${SETTINGS_FILE}" "text"
    +
    +			# Because the user doesn't change the camera number directly it's
    +			# not updated in the settings file, so we have to do it.
    +			if [[ -z ${CAMERA_NUMBER} ]]; then
    +				# This uses the CC_FILE just created.
    +				CAMERA_NUMBER="$( settings ".cameraNumber" "${CC_FILE}" )"
    +				CAMERA_NUMBER=${CAMERA_NUMBER:-0}
    +			fi
    +			update_json_file ".cameranumber" "${CAMERA_NUMBER}" "${SETTINGS_FILE}" "integer"
    +
    +			if [[ ${ADD_NEW_SETTINGS} == "true" ]]; then
    +				add_new_settings "${SETTINGS_FILE}" "${OPTIONS_FILE}" "${FROM_INSTALL}"
     			fi
    -			[[ ${OK} == "false" ]] && exit 2
     
     			# Don't do anything else if ${CAMERA_TYPE_ONLY} is set.
     			if [[ ${CAMERA_TYPE_ONLY} == "true" ]]; then
    @@ -374,94 +502,87 @@ do
     					exit 0
     				fi
     			fi
    -
    -			SHOW_POSTDATA_MESSAGE="false"	# user doesn't need to see this output
    -			CAMERA_TYPE_CHANGED="true"
    -			NEEDS_RESTART="true"
     			;;
     
    -		type)
    +		"type")
     			check_filename_type "$( settings '.filename' )" "${NEW_VALUE}" || OK="false"
    -			NEEDS_RESTART="true"
     			;;
     
    -		filename)
    +		"filename")
     			if check_filename_type "${NEW_VALUE}" "$( settings '.type' )" ; then
     				check_website && WEBSITE_CONFIG+=("config.imageName" "${LABEL}" "${NEW_VALUE}")
    -				NEEDS_RESTART="true"
     			else
     				OK="false"
     			fi
     			;;
     
    -		extratext)
    +		"usedarkframes")
    +			if [[ ${NEW_VALUE} == "true" ]]; then
    +				if [[ ! -d ${ALLSKY_DARKS} ]]; then
    +					echo -en "${wWARNING}"
    +					echo -n "WARNING: No darks to subtract.  No '${ALLSKY_DARKS}' directory.${NC}"
    +					# Restore to old value
    +					echo "${BR}Disabling ${WSNs}${LABEL}${WSNe}."
    +					update_json_file ".${KEY}" "${OLD_VALUE}" "${SETTINGS_FILE}" "boolean"
    +				else
    +					NUM_DARKS=$( find "${ALLSKY_DARKS}" -name "*.${EXTENSION}" 2>/dev/null | wc -l)
    +					if [[ ${NUM_DARKS} -eq 0 ]]; then
    +						echo -n "WARNING: ${WSNs}${LABEL}${WSNe} is set but there are no darks"
    +						echo -n " in '${ALLSKY_DARKS}' with extension of '${EXTENSION}'."
    +						echo    "${BR}FIX: Either disable the setting or take dark frames."
    +					fi
    +				fi
    +			fi
    +			;;
    +
    +		"extratext")
     			# It's possible the user will create/populate the file while Allsky is running,
     			# so it's not an error if the file doesn't exist or is empty.
     			if [[ -n ${NEW_VALUE} ]]; then
     				if [[ ! -f ${NEW_VALUE} ]]; then
    -					echo -e "${wWARNING}WARNING: '${NEW_VALUE}' does not exist; please change it.${wNC}"
    +					X=" does not exist"
     				elif [[ ! -s ${NEW_VALUE} ]]; then
    -					echo -e "${wWARNING}WARNING: '${NEW_VALUE}' is empty; please change it.${wNC}"
    +					X=" is empty"
     				fi
    +				echo -e "${wWARNING}WARNING: '${NEW_VALUE}' ${X}; please change it.${wNC}"
     			fi
    -			NEEDS_RESTART="true"
    -			;;
    -
    -		latitude | longitude)
    -			# Allow either +/- decimal numbers, OR numbers with N, S, E, W, but not both.
    -			if NEW_VALUE="$(convertLatLong "${NEW_VALUE}" "${KEY}")" ; then
    -				check_website && WEBSITE_CONFIG+=(config."${KEY}" "${LABEL}" "${NEW_VALUE}")
    -			else
    -				echo -e "${wWARNING}WARNING: ${NEW_VALUE}.${wNC}"
    -			fi
    -			NEEDS_RESTART="true"
    -			TWILIGHT_DATA_CHANGED="true"
     			;;
     
    -		angle)
    -			NEEDS_RESTART="true"
    -			TWILIGHT_DATA_CHANGED="true"
    -			;;
    -
    -		takedaytimeimages)
    -			NEEDS_RESTART="true"
    -			TWILIGHT_DATA_CHANGED="true"
    -			;;
    -
    -		config)
    +		"config")
     			if [[ ${NEW_VALUE} == "" ]]; then
     				NEW_VALUE="[none]"
     			elif [[ ${NEW_VALUE} != "[none]" ]]; then
     				if [[ ! -f ${NEW_VALUE} ]]; then
    -					echo -e "${wWARNING}WARNING: Configuration File '${NEW_VALUE}' does not exist; please change it.${wNC}"
    +					X=" does not exist"
     				elif [[ ! -s ${NEW_VALUE} ]]; then
    -					echo -e "${wWARNING}WARNING: Configuration File '${NEW_VALUE}' is empty; please change it.${wNC}"
    +					X=" is empty"
     				fi
    +				echo -e "${wWARNING}WARNING: Configuration file '${NEW_VALUE}' ${X}; please change it.${wNC}"
     			fi
     			;;
     
    -		daytuningfile | nighttuningfile)
    +		"daytuningfile" | "nighttuningfile")
     			if [[ -n ${NEW_VALUE} && ! -f ${NEW_VALUE} ]]; then
    -				echo -e "${wWARNING}WARNING: Tuning File '${NEW_VALUE}' does not exist; please change it.${wNC}"
    +				echo -ne "${wWARNING}"
    +				echo -n "WARNING: Tuning File '${NEW_VALUE}' does not exist; please change it."
    +				echo -e "${wNC}"
     			fi
    -			NEEDS_RESTART="true"
     			;;
     
    -		displaysettings)
    -			if [[ ${NEW_VALUE} -eq 0 ]]; then
    -				NEW_VALUE="false"
    -			else
    -				NEW_VALUE="true"
    -			fi
    +		"displaysettings")
    +			[[ ${NEW_VALUE} != "false" ]] && NEW_VALUE="true"
     			if check_website; then
     				# If there are two Websites, this gets the index in the first one.
     				# Let's hope it's the same index in the second one...
     				PARENT="homePage.popoutIcons"
    -				INDEX=$(getJSONarrayIndex "${WEB_CONFIG_FILE}" "${PARENT}" "Allsky Settings")
    +				INDEX=$( getJSONarrayIndex "${WEB_CONFIG_FILE}" "${PARENT}" "Allsky Settings" )
     				if [[ ${INDEX} -ge 0 ]]; then
     					WEBSITE_CONFIG+=("${PARENT}[${INDEX}].display" "${LABEL}" "${NEW_VALUE}")
     				else
    -					echo -e "${wWARNING}WARNING: Unable to update ${wBOLD}${LABEL}${wNBOLD} in ${WEB_CONFIG_FILE}; ignoring.${wNC}"
    +					echo -en "${wWARNING}"
    +					echo -en "WARNING: Unable to update ${wBOLD}${LABEL}${wNBOLD}"
    +					echo -en " in ${WEB_CONFIG_FILE}; ignoring."
    +					echo -e "${wNC}"
     				fi
     			else
     				echo -en "${wWARNING}"
    @@ -472,36 +593,220 @@ do
     			fi
     			;;
     
    -		showonmap)
    -			SHOW_ON_MAP="1"
    -			[[ ${NEW_VALUE} -eq 0 ]] && POSTTOMAP_ACTION="--delete"
    +		"showonmap")
    +			SHOW_ON_MAP="true"
    +			[[ ${NEW_VALUE} == "false" ]] && POSTTOMAP_ACTION="--delete"
     			RUN_POSTTOMAP="true"
     			;;
     
    -		location | owner | camera | lens | computer)
    +		"latitude" | "longitude")
    +			# Allow either +/- decimal numbers, OR numbers with N, S, E, W, but not both.
    +			if LAT_LON="$( convertLatLong "${NEW_VALUE}" "${KEY}" 2>&1 )" ; then
    +				check_website && WEBSITE_CONFIG+=(config."${KEY}" "${LABEL}" "${LAT_LON}")
    +				RUN_POSTTOMAP="true"
    +			else
    +				# Restore to old value
    +				echo -en "${wERROR}${LAT_LON}${wNC}"
    +				echo "${BR}Setting ${WSNs}${LABEL}${WSNe} back to ${WSVs}${OLD_VALUE}${WSVe}."
    +				update_json_file ".${KEY}" "${OLD_VALUE}" "${SETTINGS_FILE}" "string"
    +				OK="false"
    +			fi
    +			;;
    +
    +		"location" | "owner" | "camera" | "lens" | "computer")
     			RUN_POSTTOMAP="true"
     			check_website && WEBSITE_CONFIG+=(config."${KEY}" "${LABEL}" "${NEW_VALUE}")
     			;;
     
    -		websiteurl | imageurl)
    +
    +		"uselocalwebsite")
    +			if [[ ${NEW_VALUE} == "true" ]]; then
    +				prepare_local_website ""
    +				"${ALLSKY_SCRIPTS}/postData.sh" --fromWebUI --allfiles
    +			fi
    +			;;
    +
    +		"remotewebsiteurl" | "remotewebsiteimageurl")
    +			CHECK_REMOTE_WEBSITE_ACCESS="true"
     			RUN_POSTTOMAP="true"
     			;;
     
    -		overlaymethod)
    +		"useremotewebsite")
    +			[[ ${NEW_VALUE} == "true" ]] && CHECK_REMOTE_WEBSITE_ACCESS="true"
    +			;;
    +
    +		"remotewebsiteprotocol" | "remotewebsiteimagedir")
    +			CHECK_REMOTE_WEBSITE_ACCESS="true"
    +			;;
    +
    +		remotewebsite_*)		# from REMOTE_WEBSITE_* settings in env file
    +			CHECK_REMOTE_WEBSITE_ACCESS="true"
    +			;;
    +
    +		"useremoteserver")
    +			[[ ${NEW_VALUE} == "true" ]] && CHECK_REMOTE_SERVER_ACCESS="true"
    +			;;
    +
    +		"remoteserverprotocol" | "remoteserverimagedir")
    +			CHECK_REMOTE_SERVER_ACCESS="true"
    +			;;
    +
    +		remoteserver_*)			# from env file
    +			CHECK_REMOTE_SERVER_ACCESS="true"
    +			;;
    +
    +		"overlaymethod")
     			if [[ ${NEW_VALUE} -eq 1 ]]; then		# 1 == "overlay" method
    -				echo -en "${wWARNING}"
    +				echo -en "${wWARNING}WARNING: "
     				echo -en "NOTE: You must enable the ${wBOLD}Overlay Module${wNBOLD} in the"
     				echo -en " ${wBOLD}Daytime Capture${wNBOLD} and/or"
     				echo -en " ${wBOLD}Nighttime Capture${wNBOLD} flows of the"
     				echo -en " ${wBOLD}Module Manager${wNBOLD}"
     				echo -en " for the '${LABEL}' to take effect."
     				echo -e "${wNC}"
    +			else
    +				rm -f "${ALLSKY_TMP}/overlaydebug.txt"
    +			fi
    +			;;
    +
    +		"takedaytimeimages" | "takenighttimeimages")
    +:
    +###### TODO anything to do for these?
    +			;;
    +
    +		"timelapsewidth" | "timelapseheight")
    +			DID_TIMELAPSE="${DID_TIMELAPSE:-false}"
    +			if [[ ${NEW_VALUE} != "0" ]]; then
    +				# Check the KEY by itself then both numbers together.
    +				if [[ ${KEY} == "timelapsewidth" ]]; then
    +					MAX="${C_sensorWidth}"
    +				else
    +					MAX="${C_sensorHeight}"
    +				fi
    +				MIN=2
    +
    +				THIS_OK="true"
    +#XX echo "CALLING: checkPixelValue 'Timelapse ${LABEL}' '${NEW_VALUE}' '${MIN}' '${MAX}'"
    +				if ! checkPixelValue "Timelapse ${LABEL}" "sensor size" "${NEW_VALUE}" "${MIN}" "${MAX}" ; then
    +#XX echo "    FALSE"
    +					THIS_OK="false"
    +				else
    +					if [[ ${DID_TIMELAPSE} == "false" ]]; then
    +#XX echo "CALLING: checkWidthHeight 'Timelapse' 'timelapse' '${S_timelapsewidth}' '${S_timelapseheight}' '${C_sensorWidth}' '${C_sensorHeight}'"
    +						if ! checkWidthHeight "Timelapse" "timelapse" \
    +						"${S_timelapsewidth}" "${S_timelapseheight}" \
    +	 					"${C_sensorWidth}" "${C_sensorHeight}" 2>&1 ; then
    +#XX echo "false"
    +							THIS_OK="false"
    +						fi
    +						DID_TIMELAPSE="true"
    +					fi
    +				fi
    +
    +				if [[ ${THIS_OK} == "false" ]]; then
    +					# Restore to old value
    +					echo "Setting ${WSNs}Timelapse ${LABEL}${WSNe} back to ${WSVs}${OLD_VALUE}${WSVe}."
    +					update_json_file ".${KEY}" "${OLD_VALUE}" "${SETTINGS_FILE}" "number"
    +					OK="false"
    +				fi
    +			fi
    +			;;
    +
    +		"minitimelapsewidth" | "minitimelapseheight")
    +			if ! ERR="$( checkWidthHeight "Mini-Timelapse" "mini-timelapse" \
    +				"${S_minitimelapsewidth}" "${S_minitimelapseheight}" \
    +				"${C_sensorWidth}" "${C_sensorHeight}" 2>&1 )" ; then
    +
    +				# Restore to old value
    +				update_json_file ".${KEY}" "${OLD_VALUE}" "${SETTINGS_FILE}" "number"
    +			fi
    +			;;
    +
    +		"imageresizewidth" | "imageresizeheight")
    +			if ! ERR="$( checkWidthHeight "Image RESIZE" \
    +				"${S_imageresizeWidth}" "${S_imageresizeHeight}" \
    +	 			"${C_sensorWidth}" "${C_sensorHeight}" 2>&1 )" ; then
    +
    +				# Restore to old value
    +				update_json_file ".${KEY}" "${OLD_VALUE}" "${SETTINGS_FILE}" "number"
    +			fi
    +			;;
    +
    +		"imagecroptop" | "imagecropright" | "imagecropbottom" | "imagecropleft")
    +
    +			if [[ $((S_imagecroptop + S_imagecropright + BOTTOM + LEFT)) -gt 0 ]]; then
    +				ERR="$( checkCropValues "${S_imagecroptop}" "${S_imagecropright}" \
    +					"${S_imagecropbottom}" "${S_imagecropleft}" \
    +					"${C_sensorWidth}" "${C_sensorHeight}" )"
    +				if [[ $? -ne 0 ]]; then
    +					MSG="ERROR: ${ERR}${BR}"
    +					MSG+="FIX: Check the ${WSNs}Image Crop Top/Right/Bottom/Left${WSNe} settings."
    +					echo -e "${MSG}"
    +				fi
    +			fi
    +			;;
    +
    +		"timelapsevcodec")
    +			if ! ffmpeg -encoders 2>/dev/null | awk -v codec="${NEW_VALUE}" '
    +				BEGIN { exit_code = 1; }
    +				{ if ($2 == codec) { exit_code = 0; exit 0; } }
    +				END { exit exit_code; }' ; then
    +
    +				MSG="${wWARNING}WARNING: "
    +				MSG+="Unknown VCODEC: '${NEW_VALUE}'; resetting to '${OLD_VALUE}'."
    +				MSG+="${BR}Execute: ffmpeg -encoders"
    +				MSG+="${BR}for a list of VCODECs."
    +				MSG+="${wNC}"
    +				echo -e "${MSG}"
    +
    +				# Restore to old value
    +				update_json_file ".${KEY}" "${OLD_VALUE}" "${SETTINGS_FILE}" "text"
    +			fi
    +			;;
    +
    +		"timelapsepixfmt")
    +			if ! ffmpeg -pix_fmts 2>/dev/null | awk -v fmt="${NEW_VALUE}" '
    +				BEGIN { exit_code = 1; }
    +				{ if ($2 == fmt) { exit_code = 0; exit 0; } }
    +				END { exit exit_code; }' ; then
    +
    +				MSG="${wWARNING}WARNING: "
    +				MSG+="Unknown Pixel Format: '${NEW_VALUE}'; resetting to '${OLD_VALUE}'."
    +				MSG+="Execute: ffmpeg -pix_fmts"
    +				MSG+="for a list of formats."
    +				MSG+="${wNC}"
    +				echo -e "${MSG}"
    +
    +				# Restore to old value
    +				update_json_file ".${KEY}" "${OLD_VALUE}" "${SETTINGS_FILE}" "text"
    +			fi
    +			;;
    +
    +		"daystokeep" | "daystokeeplocalwebsite" | "daystokeepremotewebsite")
    +			if [[ ${NEW_VALUE} -gt 0 ]]; then
    +:	# TODO: Check how many days images there are of the specified type.
    +	# For remote website, query the website for the number (to be implemented).
    +	# If MORE than NEW_VALUE, warn the user since those images will be deleted
    +	# at the next endOfNight.sh run.
     			fi
     			;;
     
    +		"uselogin")
    +			if [[ ${NEW_VALUE} == "false" ]]; then
    +				MSG="${wWARNING}WARNING: "
    +				MSG+="Disabling '${LABEL}' should NOT be done if your Pi is"
    +				MSG+=" accessible on the Internet.  It's a HUGE security risk!"
    +				MSG+="${wNC}"
    +				echo -e "${MSG}"
    +			fi
    +			;;
     
     		*)
    -			echo -e "${wWARNING}WARNING: Unknown label '${LABEL}', key='${KEY}'; ignoring.${wNC}"
    +			MSG="${wWARNING}WARNING: "
    +			MSG+="Unknown key '${KEY}'; ignoring.  Old=${OLD_VALUE}, New=${NEW_VALUE}"
    +			MSG+="${wNC}"
    +			echo -e "${MSG}"
    +			((NUM_CHANGED--))
     			;;
     
     		esac
    @@ -510,27 +815,37 @@ done
     
     [[ ${OK} == "false" ]] && exit 1
     
    -if check_website ; then
    -	# Anytime a setting in settings.json changed we want to
    -	# send an updated file to all Allsky Website(s).
    -	[[ ${DEBUG} == "true" ]] && echo -e "${wDEBUG}Executing postData.sh${NC}"
    -	x=""
    -	[[ ${TWILIGHT_DATA_CHANGED} == "false" ]] && x="${x} --settingsOnly"
    -	[[ ${CAMERA_TYPE_CHANGED}   == "false" ]] && x="${x} --allFiles"
    -
    -	# shellcheck disable=SC2086
    -	if RESULT="$( "${ALLSKY_SCRIPTS}/postData.sh" ${x} >&2 )" ; then
    -		if [[ ${SHOW_POSTDATA_MESSAGE} == "true" ]]; then
    -			if [[ ${TWILIGHT_DATA_CHANGED} == "true" ]]; then
    -				echo -en "${wOK}"
    -				echo -e "Updated twilight data sent to your Allsky Website."
    -				echo -e "${wBOLD}If you have the Allsky Website open in a browser, please refresh the window.${wNBOLD}"
    -				echo -en "${wNC}"
    -			fi
    -			# Users don't need to know that the settings file and possibly others were sent.
    +[[ ${NUM_CHANGED} -le 0 ]] && exit 0		# Nothing changed
    +
    +USE_REMOTE_WEBSITE="$( settings ".useremotewebsite" )"
    +USE_REMOTE_SERVER="$( settings ".useremoteserver" )"
    +if [[ ${USE_REMOTE_WEBSITE} == "true" || ${USE_REMOTE_SERVER} == "true" ]]; then
    +	if [[ ! -f ${ALLSKY_ENV} ]]; then
    +		cp "${REPO_ENV_FILE}" "${ALLSKY_ENV}"
    +	fi
    +
    +	if [[ ${USE_REMOTE_WEBSITE} == "true" && ${CHECK_REMOTE_WEBSITE_ACCESS} == "true" ]]; then
    +		# testUpload.sh displays error messages
    +		"${ALLSKY_SCRIPTS}/testUpload.sh" --website
    +
    +		# If the remote configuration file doesn't exist assume it's because
    +		# the user enabled it but hasn't yet "installed" it (which creates the file).
    +		if [[ ! -s ${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE} ]]; then
    +			MSG="${wWARNING}WARNING: "
    +			MSG+="The Remote Website is now enabled but hasn't been installed yet."
    +			MSG+="${BR}Please do so now."
    +			if [[ ${ON_TTY} == "false" ]]; then		# called from WebUI.
    +				MSG+="${BR}See <a allsky='true' external='true'"
    +				MSG+=" href='/documentation/installations/AllskyWebsite.html'>the documentation</a>"
    +			fi
    +			MSG+="${wNC}"
    +			echo -e "${MSG}"
    +			[[ ${WEBSITES} != "local" ]] && WEBSITES=""
     		fi
    -	else
    -		echo -e "${wERROR}ERROR posting updated twilight data: ${RESULT}.${wNC}"
    +	fi
    +
    +	if [[ ${USE_REMOTE_SERVER} == "true" && ${CHECK_REMOTE_SERVER_ACCESS} == "true" ]]; then
    +		"${ALLSKY_SCRIPTS}/testUpload.sh" --server
     	fi
     fi
     
    @@ -539,52 +854,45 @@ if [[ ${#WEBSITE_CONFIG[@]} -gt 0 ]]; then
     	# Update the local and/or Website remote config file
     	if [[ ${WEBSITES} == "local" || ${WEBSITES} == "both" ]]; then
     		if [[ ${DEBUG} == "true" ]]; then
    -			echo -e "${wDEBUG}Executing updateWebsiteConfig.sh local${NC}"
    +			echo -e "${wDEBUG}Executing updateWebsiteConfig.sh local${wNC}"
     		fi
     		# shellcheck disable=SC2086
     		"${ALLSKY_SCRIPTS}/updateWebsiteConfig.sh" ${DEBUG_ARG} --local "${WEBSITE_CONFIG[@]}"
     	fi
     	if [[ ${WEBSITES} == "remote" || ${WEBSITES} == "both" ]]; then
     		if [[ ${DEBUG} == "true" ]]; then
    -			echo -e "${wDEBUG}Executing updateWebsiteConfig.sh remote${NC}"
    +			echo -e "${wDEBUG}Executing updateWebsiteConfig.sh remote${wNC}"
     		fi
     		# shellcheck disable=SC2086
     		"${ALLSKY_SCRIPTS}/updateWebsiteConfig.sh" ${DEBUG_ARG} --remote "${WEBSITE_CONFIG[@]}"
     
     		FILE_TO_UPLOAD="${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}"
    -		TO="${IMAGE_DIR}"
    +
    +		IMAGE_DIR="$( settings ".remotewebsiteimagedir" )"
     		if [[ ${DEBUG} == "true" ]]; then
    -			echo -e "${wDEBUG}Uploading '${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}' to ${TO:-root}${wNC}"
    +			echo -e "${wDEBUG}Uploading '${FILE_TO_UPLOAD}' to remote Website.${wNC}"
     		fi
     
    -		"${ALLSKY_SCRIPTS}/upload.sh" --silent \
    -			"${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}" \
    -			"${TO}" \
    -			"${ALLSKY_WEBSITE_CONFIGURATION_NAME}" \
    -			"RemoteWebsite"
    -		R=$?
    -		if [[ ${R} -ne 0 ]]; then
    -			echo -e "${RED}${ERROR_PREFIX}Unable to upload '${FILE_TO_UPLOAD}'.${NC}"
    +		if ! "${ALLSKY_SCRIPTS}/upload.sh" --silent --remote-web \
    +				"${FILE_TO_UPLOAD}" \
    +				"${IMAGE_DIR}" \
    +				"${ALLSKY_WEBSITE_CONFIGURATION_NAME}" \
    +				"RemoteWebsite" ; then
    +			echo -e "${wERROR}${ERROR_PREFIX}Unable to upload '${FILE_TO_UPLOAD}' to Website ${NUM}.${wNC}"
     		fi
     	fi
     fi
     
     if [[ ${RUN_POSTTOMAP} == "true" ]]; then
     	[[ -z ${SHOW_ON_MAP} ]] && SHOW_ON_MAP="$( settings ".showonmap" )"
    -	if [[ ${SHOW_ON_MAP} == "1" ]]; then
    -		[[ ${DEBUG} == "true" ]] && echo -e "${wDEBUG}Executing postToMap.sh${NC}"
    +	if [[ ${SHOW_ON_MAP} == "true" ]]; then
    +		[[ ${DEBUG} == "true" ]] && echo -e "${wDEBUG}Executing postToMap.sh${wNC}"
    +# TODO: put in background to return to user faster?
     		# shellcheck disable=SC2086
     		"${ALLSKY_SCRIPTS}/postToMap.sh" --whisper --force ${DEBUG_ARG} ${POSTTOMAP_ACTION}
     	fi
     fi
     
    -if [[ ${RESTARTING} == "false" && ${NEEDS_RESTART} == "true" ]]; then
    -	echo -en "${wOK}${wBOLD}"
    -	echo "*** You must restart Allsky for your change to take affect. ***"
    -	echo -en "${wNBOLD}${wNC}"
    -fi
    -
    -
     if [[ ${GOT_WARNING} == "true" ]]; then
     	exit 255
     else
    diff --git a/scripts/modules/allsky_loadimage.py b/scripts/modules/allsky_loadimage.py
    index f0f60c282..518177400 100644
    --- a/scripts/modules/allsky_loadimage.py
    +++ b/scripts/modules/allsky_loadimage.py
    @@ -28,14 +28,9 @@ def loadimage(params, event):
         try:
             s.image = cv2.imread(s.CURRENTIMAGEPATH)
             if s.image is None:
    -            result = s.ABORT
    +            s.log(0, "ERROR: Cannot read {0}...".format(s.CURRENTIMAGEPATH), exitCode=1)
         except Exception as e:
    -        print(e)
    -        result = s.ABORT
    -
    -    if result == s.ABORT:
    -        s.log(0,"ERROR: Cannot load {0}...".format(s.CURRENTIMAGEPATH), exitCode=1)
    -    else:
    -        s.log(4, "INFO: {}".format(result))
    +        s.log(0, "ERROR: Cannot load {0}: {1}".format(s.CURRENTIMAGEPATH, e), exitCode=1)
     
    +    s.log(4, "INFO: {}".format(result))
         return result        
    diff --git a/scripts/modules/allsky_maskimage.py b/scripts/modules/allsky_maskimage.py
    index 9a878f82d..0c2dbc68b 100644
    --- a/scripts/modules/allsky_maskimage.py
    +++ b/scripts/modules/allsky_maskimage.py
    @@ -41,7 +41,7 @@ def maskimage(params, event):
         result = ""
         mask = params['mask']
         if (mask is not None) and (mask != ""):
    -        maskPath = os.path.join(s.getEnvironmentVariable("ALLSKY_OVERLAY"),"images",mask)
    +        maskPath = os.path.join(s.ALLSKY_OVERLAY, "images", mask)
             maskImage = cv2.imread(maskPath,cv2.IMREAD_GRAYSCALE)
             if maskImage is not None:
                 maskChannels = maskImage.shape[-1] if maskImage.ndim == 3 else 1
    @@ -55,15 +55,15 @@ def maskimage(params, event):
                 if (maskWidth == imageWidth) and (maskHeight == imageHeight):            
                     s.image = cv2.bitwise_and(s.image,s.image,mask = maskImage)
                     result = "Mask {0} applied".format(maskPath)
    -                s.log(4,f"INFO: {result}")
    +                s.log(4, f"INFO: {result}")
                 else:
    -                result = f"Mask {mask} is the incorrct size {maskWidth}x{maskHeight} Main image is {imageWidth}x{imageHeight}"
    -                s.log(0,f"ERROR: {result}")
    +                result = f"Mask {mask} is incorrect size: {maskWidth}x{maskHeight}. Main image is {imageWidth}x{imageHeight}."
    +                s.log(0, f"ERROR: {result}")
             else:
    -            s.log(0,"ERROR: Unable to read the mask image {0}".format(maskPath))
                 result = "Mask {0} not found".format(maskPath)
    +            s.log(0, f"ERROR: {result}")
         else:
    -        s.log(0,"ERROR: No mask defined")
             result = "No mask defined"
    +        s.log(0, f"ERROR: {result}")
     
         return result
    diff --git a/scripts/modules/allsky_meteor.py b/scripts/modules/allsky_meteor.py
    index e3195c4c5..354c3cbbf 100644
    --- a/scripts/modules/allsky_meteor.py
    +++ b/scripts/modules/allsky_meteor.py
    @@ -14,7 +14,7 @@
     
     metaData = {
         "name": "AllSKY Meteor Detection",
    -    "description": "Detects meteors in images",  
    +    "description": "Detects meteors in images",
         "events": [
             "night"
         ],
    @@ -34,7 +34,7 @@
                 "help": "The name of the image mask. This mask is applied when detecting meteors bit not visible in the final image",
                 "type": {
                     "fieldtype": "image"
    -            }                
    +            }
             },
             "length" : {
                 "required": "true",
    @@ -45,34 +45,34 @@
                     "min": 0,
                     "max": 500,
                     "step": 1
    -            }          
    +            }
             },
             "useclearsky" : {
                 "required": "false",
                 "description": "Use Clear Sky",
    -            "help": "If available use the results of the clear sky module. If the sky is not clear meteor detection will be skipped",         
    +            "help": "If available use the results of the clear sky module. If the sky is not clear meteor detection will be skipped",
                 "type": {
                     "fieldtype": "checkbox"
    -            }          
    -        },            
    +            }
    +        },
             "annotate" : {
                 "required": "false",
                 "description": "Annotate Meteors",
                 "help": "If selected the identified meteors in the image will be highlighted",
    -            "tab": "Debug",            
    +            "tab": "Debug",
                 "type": {
                     "fieldtype": "checkbox"
    -            }          
    +            }
             },
             "debug" : {
                 "required": "false",
                 "description": "Enable debug mode",
                 "help": "If selected each stage of the detection will generate images in the allsky tmp debug folder",
    -            "tab": "Debug",            
    +            "tab": "Debug",
                 "type": {
                     "fieldtype": "checkbox"
    -            }          
    -        }                          
    +            }
    +        }
         }
     }
     
    @@ -88,19 +88,20 @@ def meteor(params, event):
         if not rainFlag:
             if skyClear:
                 mask = params["mask"]
    -            annotate = params["annotate"]    
    +            annotate = params["annotate"]
                 length = s.int(params["length"])
                 debug = params["debug"]
     
                 maskImage = None
    -            
    +            maskPath = ""
    +
                 if debug:
                     s.startModuleDebug(metaData["module"])
     
                 height, width = s.image.shape[:2]
     
                 if mask != "":
    -                maskPath = os.path.join(s.getEnvironmentVariable("ALLSKY_OVERLAY"),"images",mask)
    +                maskPath = os.path.join(s.ALLSKY_OVERLAY, "images", mask)
                     s.log(4,f"INFO: Loading mask {maskPath}")
                     maskImage = cv2.imread(maskPath,cv2.IMREAD_GRAYSCALE)
                     if maskImage is not None:
    @@ -150,10 +151,10 @@ def meteor(params, event):
     
                 if maskImage is not None:
                     try:
    -                    dilation_mask = cv2.bitwise_and(dilation_mask,dilation_mask,mask = maskImage)
    +                    dilation_mask = cv2.bitwise_and(dilation_mask, dilation_mask, mask = maskImage)
                     except Exception as ex:
    -                    s.log(0,"ERROR: There is a problem with the meteor mask. Please check the masks dimensions and colour depth")
    -        
    +                    s.log(0, f"ERROR: There is a problem with the meteor mask {maskPath}. Check the mask's dimensions and colour depth.", exitCode=1)
    +
                     if debug:
                         s.writeDebugImage(metaData["module"], "dilation-mask.png", dilation_mask)
     
    @@ -169,19 +170,19 @@ def meteor(params, event):
                                 if annotate:
                                     cv2.line(s.image,(x1,y1),(x2,y2),(0,255,0),10)
                         lineCount += 1
    -            
    -            os.environ["AS_METEORLINECOUNT"] = str(lineCount)    
    -            os.environ["AS_METEORCOUNT"] = str(meteorCount)
    -            result = "{0} Meteors found, {1} Lines detected".format(meteorCount, lineCount)
    -            s.log(4,"INFO: {}".format(result))
    +
    +            s.setEnvironmentVariable("AS_METEORLINECOUNT", str(lineCount))
    +            s.setEnvironmentVariable("AS_METEORCOUNT", str(meteorCount))
    +            result = f"{meteorCount} Meteors found, {lineCount} Lines detected"
    +            s.log(4, f"INFO: {result}")
             else:
                 result = "Sky is not clear so ignoring meteor detection"
    -            s.log(4,"INFO: {0}".format(result))
    -            os.environ["AS_METEORCOUNT"] = "Disabled"            
    +            s.log(4, f"INFO: {result}")
    +            s.setEnvironmentVariable("AS_METEORCOUNT", "Disabled")
         else:
             result = "Its raining so ignorning meteor detection"
    -        s.log(4,"INFO: {0}".format(result))
    -        os.environ["AS_METEORCOUNT"] = "Disabled"
    +        s.log(4, f"INFO: {result}")
    +        s.setEnvironmentVariable("AS_METEORCOUNT", "Disabled")
     
         return result
     
    diff --git a/scripts/modules/allsky_overlay.py b/scripts/modules/allsky_overlay.py
    index 58ffab4ab..c9561a734 100644
    --- a/scripts/modules/allsky_overlay.py
    +++ b/scripts/modules/allsky_overlay.py
    @@ -35,10 +35,13 @@
     
     import locale
     
    -try:
    -    locale.setlocale(locale.LC_ALL, '')
    -except:
    -    pass
    +#try:
    +#    locale.setlocale(locale.LC_ALL, 'de_DE.UTF-8')
    +#except:
    +#    pass
    +
    +formatErrorPlaceholder = "??"
    +missingTypePlaceholder = "???"
     
     metaData = {
         "name": "Overlays data on the image",
    @@ -67,16 +70,18 @@ class ALLSKYOVERLAY:
         _OVERLAYCONFIGFILE = 'overlay.json'
         _OVERLAYFIELDSFILE = 'fields.json'
         _OVERLAYUSERFIELDSFILE = 'userfields.json'
    +    _OVERLAYOECONFIG = 'oe-config.json'
         _OVERLAYTMPFOLDER = ''
         _OVERLAYTLEFOLDER = None
         _overlayConfigFile = None
         _overlayConfig = None
         _image = None
         _fonts = {}
    +    _fontMsgs = {}
         _fields = {}
         _systemfields = {}
         _userfields = {}
    -        
    +
         _extraData = {}
     
         _startTime = 0
    @@ -93,46 +98,106 @@ class ALLSKYOVERLAY:
         _debug = False
         _enableSkyfield = True
         _formaterrortext = ""
    +    _notEnabled = ""
    +    _errorType = "XX_ERROR_XX"
    +    _checkVariableType = True
    +    _displayedTypeError = False
     
    +    _errors = ''
    +    _oeConfig = None
    +    
         def __init__(self, formaterrortext):
    -        self._overlayConfigFile = os.path.join(os.environ['ALLSKY_OVERLAY'], 'config', self._OVERLAYCONFIGFILE)
    -        fieldsFile = os.path.join(os.environ['ALLSKY_OVERLAY'], 'config', self._OVERLAYFIELDSFILE)
    -        userFieldsFile = os.path.join(os.environ['ALLSKY_OVERLAY'], 'config', self._OVERLAYUSERFIELDSFILE)
    -        
    -        tmpFolder = os.path.join(os.environ['ALLSKY_OVERLAY'], 'tmp')
    +        C = os.path.join(s.ALLSKY_OVERLAY, 'config')
    +        self._overlayConfigFile = os.path.join(C, self._OVERLAYCONFIGFILE)
    +        self._loadOverlay()
    +        fieldsFile = os.path.join(C, self._OVERLAYFIELDSFILE)
    +        userFieldsFile = os.path.join(C, self._OVERLAYUSERFIELDSFILE)
    +
    +        tmpFolder = os.path.join(C, 'tmp')
             self._createTempDir(tmpFolder)
             self._OVERLAYTMP = os.path.join(tmpFolder, 'overlay')
             self._createTempDir(self._OVERLAYTMP)
             self._OVERLAYTLEFOLDER = os.path.join(self._OVERLAYTMP , 'tle')
             self._createTempDir(self._OVERLAYTLEFOLDER)
    -        
    +
             with open(fieldsFile) as file:
    -            self._systemfields = json.load(file)['data']
    +            try:
    +                self._systemfields = json.load(file)['data']
    +            except Exception as err:
    +                # This will cause errors for each variable in the overlay since its 'type'
    +                # will be unknown.  _checkVariableType=False indicates not to check.
    +                self._checkVariableType = False
    +                s.log(0, f"ERROR: Unable to read '{fieldsFile}' {err}", sendToAllsky=True)
    +                self._systemfields = []
             with open(userFieldsFile) as file:
    -            self._userfields = json.load(file)['data']    
    +            try:
    +                self._userfields = json.load(file)['data']
    +            except Exception as err:
    +                s.log(0, f"ERROR: Unable to read '{userFieldsFile}' {err}", sendToAllsky=True)
    +                self._userfields = []
    +                self._checkVariableType = False
     
             self._fields = self._systemfields + self._userfields
     
    -        s.log(4, "INFO: Config file set to '{}'.".format(self._overlayConfigFile))
    +        s.log(4, f"INFO: Config file set to '{self._overlayConfigFile}'.")
             self._enableSkyfield = True
             try:
                 load = Loader(self._OVERLAYTMP, verbose=False)
                 self._eph = load('de421.bsp')
             except Exception as err:
    -            s.log(0, "ERROR: Unable to download de421.bsp: {}".format(err))
    +            s.log(0, f"ERROR: Unable to download de421.bsp: {err}")
                 self._enableSkyfield = False
             self._setDateandTime()
             self._observerLat = s.getSetting('latitude')
             self._observerLon = s.getSetting('longitude')
             self._debug = True
     
    -        self._formaterrortext = formaterrortext;
    +        self._formaterrortext = formaterrortext
    +        
    +        try:
    +            oeConfigFile = os.path.join(os.environ['ALLSKY_OVERLAY'], 'config', self._OVERLAYOECONFIG)
    +            with open(oeConfigFile) as file:
    +                self._oeConfig = json.load(file)
    +                
    +                if 'overlayErrors' not in self._oeConfig:
    +                    self._oeConfig['overlayErrors'] = True
    +                if 'overlayErrorsText' not in self._oeConfig:
    +                    self._oeConfig['overlayErrorsText'] = 'Error found; see the WebU'
    +                    
    +        except Exception as e:
    +            s.log(0,f'ERROR: Unable to read the overlay config file {oeConfigFile}')
    +            
    +    def _log(self, level, text, preventNewline = False, exitCode=None, sendToAllsky=False, addErrorToOverlay=False):
    +        s.log(level=level, text=text, preventNewline=preventNewline,exitCode=exitCode, sendToAllsky=sendToAllsky)
    +        if sendToAllsky:
    +            if self._oeConfig is not None:
    +                if self._oeConfig['overlayErrors']:
    +                    self._errors = self._oeConfig['overlayErrorsText']
    +    
    +    def _loadOverlay(self):
    +        dayORNight = os.environ['DAY_OR_NIGHT']
    +        if dayORNight == 'DAY':
    +            overlayName = s.getSetting('daytimeoverlay')
    +        else:
    +            overlayName = s.getSetting('nighttimeoverlay')
     
    +        userPath = os.path.join(os.environ['ALLSKY_OVERLAY'], 'myTemplates', overlayName)
    +        if os.path.isfile(userPath):
    +            self._overlayConfigFile = userPath
    +            self._log(4, f'INFO: Time of day is {dayORNight} using overlay {overlayName}')
    +        else:
    +            corePath = os.path.join(os.environ['ALLSKY_OVERLAY'], 'config', overlayName)
    +            if os.path.isfile(corePath):
    +                self._overlayConfigFile = corePath
    +                self._log(4, f'INFO: Time of day is {dayORNight} using overlay {overlayName}')
    +            else:
    +                self._log(0, f'ERROR: Unable to locate an overlay file: TOD {dayORNight}, overlay "{overlayName}"', sendToAllsky=True)
    +                
         def _dumpDebugData(self):
    -        debugFilePath = os.path.join(s.getEnvironmentVariable('ALLSKY_TMP'),'overlaydebug.txt')
    +        debugFilePath = os.path.join(s.ALLSKY_TMP, 'overlaydebug.txt')
             env = {}
             for var in os.environ:
    -            varValue = os.environ[var]
    +            varValue = s.getEnvironmentVariable(var, fatal=True)
                 varValue = varValue.replace('\n', '')
                 varValue = varValue.replace('\r', '')
                 var = var.ljust(50, ' ')
    @@ -143,7 +208,7 @@ def _dumpDebugData(self):
                     varValue = env[var]
                     debugFile.write(var + varValue + os.linesep)
     
    -        s.log(4, "INFO: Debug information written to {}".format(debugFilePath))
    +        s.log(4, f"INFO: Debug information written to {debugFilePath}")
     
         def _createTempDir(self, path):
             if not os.path.isdir(path):
    @@ -152,8 +217,8 @@ def _createTempDir(self, path):
                 os.umask(umask)
     
         def _setDateandTime(self):
    -        osDate = s.getEnvironmentVariable('AS_DATE', True)
    -        osTime = s.getEnvironmentVariable('AS_TIME', True)
    +        osDate = s.getEnvironmentVariable('AS_DATE', fatal=True)
    +        osTime = s.getEnvironmentVariable('AS_TIME', fatal=True)
             if osDate.startswith('20'):
                 self._imageDate = time.mktime(datetime.strptime(osDate + ' ' + osTime,"%Y%m%d %H%M%S").timetuple())
             else:
    @@ -201,12 +266,11 @@ def _loadDataFile(self):
             result = True
     
             defaultExpiry = self._overlayConfig["settings"]["defaultdatafileexpiry"]
    -        extraFolder = os.environ['ALLSKY_EXTRA']
    -
    +        extraFolder = s.getEnvironmentVariable('ALLSKY_EXTRA', fatal=True)
             for (dirPath, dirNames, fileNames) in os.walk(extraFolder):
                 for fileName in fileNames:
                     dataFilename = os.path.join(extraFolder, fileName)
    -                s.log(4, "INFO: Loading Data File {}".format(dataFilename))
    +                s.log(4, f"INFO: Loading Data File {dataFilename}")
                     self._readData(dataFilename, defaultExpiry)
     
             return result
    @@ -281,13 +345,13 @@ def _readData(self, dataFilename, defaultExpiry):
                                     expires = defaultExpiry
     
                                 if name[0:3] != 'AS_':
    -                                name = 'AS_{}'.format(name)
    -                            os.environ[name] = str(value)
    +                                name = f'AS_{name}'
    +                            s.setEnvironmentVariable(name, str(value))
                                 self._saveExtraDataField(name, fileModifiedTime, expires, x, y, fill, font, fontsize, image, rotate, scale, opacity, stroke, strokewidth)
                     except:
    -                    s.log(0, 'WARNING: Data File {} is invalid - IGNORING.'.format(dataFilename))
    +                    self._log(0, f'WARNING: Data File {dataFilename} is invalid - IGNORING.', sendToAllsky=True)
                 else:
    -                s.log(0, 'ERROR: Data File {} is not accessible - IGNORING.'.format(dataFilename))
    +                self._log(0, 'fERROR: Data File {dataFilename} is not accessible - IGNORING.', sendToAllsky=True)
                     result = False
             elif fileExtension == '.txt':
                 if s.isFileReadable(dataFilename):
    @@ -301,7 +365,7 @@ def _readData(self, dataFilename, defaultExpiry):
                             os.environ[name] = str(value)
                             self._saveExtraDataField(name, fileModifiedTime, defaultExpiry)
                 else:
    -                s.log(0, "ERROR: Data File {} is not accessible - IGNORING.".format(dataFilename))
    +                self._log(0, f"ERROR: Data File {dataFilename} is not accessible - IGNORING.", sendToAllsky=True)
                     result = False
     
             return result
    @@ -325,7 +389,7 @@ def _saveExtraDataField(self, name, fieldDate, expires, x=None, y=None, fill=Non
                 'strokewidth': strokewidth
             }
             self._extraData[name] = _extraField
    -        s.log(4,"INFO: Added extra data field {0}, expiry {1} seconds.".format(name, expires))
    +        s.log(4, f"INFO: Added extra data field {name}, expiry {expires} seconds.")
     
         def _loadConfigFile(self):
             result = True
    @@ -334,10 +398,10 @@ def _loadConfigFile(self):
                     self._overlayConfig = json.load(file)
     
                 if len(self._overlayConfig["fields"]) == 0 and len(self._overlayConfig["images"]) == 0:
    -                s.log(1, "WARNING: Config file '{}' is empty.".format(self._overlayConfigFile))
    +                s.log(1, f"WARNING: Config file '{self._overlayConfigFile}' is empty.")
                     result = True
             else:
    -            s.log(0, "ERROR: Config File '{}' not accessible.".format(self._overlayConfigFile))
    +            self._log(0, f"ERROR: Config File '{self._overlayConfigFile}' not accessible.", sendToAllsky=True)
                 result = False
     
             return result
    @@ -350,57 +414,76 @@ def _loadImageFile(self):
             result = True
     
             self._image = s.image
    +        if self._notEnabled != "":
    +            s.log(4, f'INFO: Not enabled: {self._notEnabled}')
             return result
     
         def _saveImagefile(self):
             """ Saves the final image """
             s.image = self._image
     
    -    def _timer(self, text, showIntermediate = True, showMessage=True):
    +    def _timer(self, text, showIntermediate=True, showMessage=True):
             """ Method to display the elapsed time between function calls and the total script execution time """
    -        if s.LOGLEVEL:
    -            if showMessage:
    -                if self._lastTimer is None:
    -                    elapsedSinceLastTime = datetime.now() - self._startTime
    -                else:
    -                    elapsedSinceLastTime = datetime.now() - self._lastTimer
    +        if s.LOGLEVEL and showMessage:
    +            now = datetime.now()
    +            if self._lastTimer is None:
    +                elapsedSinceLastTime = now - self._startTime
    +            else:
    +                elapsedSinceLastTime = now - self._lastTimer
     
    -                lastText = str(elapsedSinceLastTime.total_seconds())
    -                self._lastTimer = datetime.now()
    +            self._lastTimer = now
     
    -                elapsedTime = datetime.now() - self._startTime
    -                if showIntermediate:
    -                    s.log(4, "INFO: {0} took {1} seconds. Elapsed time {2} seconds.".format(text, lastText, elapsedTime.total_seconds()))
    -                else:
    -                    s.log(4, "INFO: {0} Elapsed time {1} seconds.".format(text, elapsedTime.total_seconds()))
    +            elapsedTime = now - self._startTime
    +            # Need .6f  or else really small numbers have scientific notation
    +            if showIntermediate:
    +                lastText = elapsedSinceLastTime.total_seconds()
    +                s.log(4, f"INFO: {text} took {lastText:.6f} seconds. Elapsed time {elapsedTime.total_seconds():.6f} seconds")
    +            else:
    +                s.log(4, f"INFO: {text} Elapsed time {elapsedTime.total_seconds():.6f} seconds")
     
         def _getFont(self, font, fontSize):
     
    -        systemFontMap = {
    -            'Arial': {'fontpath': '/usr/share/fonts/truetype/msttcorefonts/Arial.ttf'},
    -            'Arial Black': {'fontpath': '/usr/share/fonts/truetype/msttcorefonts/Arial_Black.ttf'},
    -            'Times New Roman': {'fontpath': '/usr/share/fonts/truetype/msttcorefonts/Times_New_Roman.ttf'},
    -            'Courier New': {'fontpath': '/usr/share/fonts/truetype/msttcorefonts/cour.ttf'},
    -            'Verdana': {'fontpath': '/usr/share/fonts/truetype/msttcorefonts/Verdana.ttf'},
    -            'Trebuchet MS': {'fontpath': '/usr/share/fonts/truetype/msttcorefonts/trebuc.ttf'},
    -            'Impact': {'fontpath': '/usr/share/fonts/truetype/msttcorefonts/Impact.ttf'},
    -            'Georgia': {'fontpath': '/usr/share/fonts/truetype/msttcorefonts/Georgia.ttf'},
    -            'Comic Sans MS': {'fontpath': '/usr/share/fonts/truetype/msttcorefonts/comic.ttf'},
    +        tt = '/usr/share/fonts/truetype/msttcorefonts'
    +        systemFontMapCased = {
    +            'Arial':           {'fontpath': f'{tt}/Arial.ttf'},
    +            'Arial Black':     {'fontpath': f'{tt}/Arial_Black.ttf'},
    +            'Times New Roman': {'fontpath': f'{tt}/Times_New_Roman.ttf'},
    +            'Courier New':     {'fontpath': f'{tt}/cour.ttf'},
    +            'Verdana':         {'fontpath': f'{tt}/Verdana.ttf'},
    +            'Trebuchet MS':    {'fontpath': f'{tt}/trebuc.ttf'},
    +            'Impact':          {'fontpath': f'{tt}/Impact.ttf'},
    +            'Georgia':         {'fontpath': f'{tt}/Georgia.ttf'},
    +            'Comic Sans MS':   {'fontpath': f'{tt}/comic.ttf'},
             }
     
    -        preMsg = "Loading '{0}' font, size {1} pixels".format(font, fontSize)
    +        systemFontMap = {
    +            'arial':           {'fontpath': f'{tt}/Arial.ttf'},
    +            'arial black':     {'fontpath': f'{tt}/Arial_Black.ttf'},
    +            'times new roman': {'fontpath': f'{tt}/Times_New_Roman.ttf'},
    +            'courier new':     {'fontpath': f'{tt}/cour.ttf'},
    +            'verdana':         {'fontpath': f'{tt}/Verdana.ttf'},
    +            'trebuchet ms':    {'fontpath': f'{tt}/trebuc.ttf'},
    +            'impact':          {'fontpath': f'{tt}/Impact.ttf'},
    +            'georgia':         {'fontpath': f'{tt}/Georgia.ttf'},
    +            'comic sans ms':   {'fontpath': f'{tt}/comic.ttf'},
    +        }
    +        
    +        preMsg = f"Loading '{font}' font, size {fontSize} pixels"
             fontPath = None
    +
    +        font = font.lower()
             if font in self._overlayConfig['fonts']:
                 fontData = self._overlayConfig['fonts'][font]
                 fontConfigPath = fontData['fontPath']
                 if fontConfigPath.startswith('/'):
                     fontConfigPath = fontConfigPath[1:]
    -            fontPath = os.path.join(os.environ['ALLSKY_CONFIG'], 'overlay', fontConfigPath)
    +            C = s.getEnvironmentVariable('ALLSKY_CONFIG', fatal=True)
    +            fontPath = os.path.join(C, 'overlay', fontConfigPath)
             else:
                 if font in systemFontMap:
                     fontPath = systemFontMap[font]['fontpath']
                 else:
    -                s.log(0, "ERROR: System font '{}' not found in internal map.".format(font))
    +                self._log(0, f"ERROR: System font '{font}' not found in internal map.", sendToAllsky=True)
     
             if fontPath is not None:
                 if fontSize is None:
    @@ -410,18 +493,20 @@ def _getFont(self, font, fontSize):
                         fontSize = self._overlayConfig['settings']['defaultfontsize']
     
                 fontKey = font + '_' + str(fontSize)
    -
                 if fontKey in self._fonts:
                     font = self._fonts[fontKey]
    -                s.log(4, F'{preMsg} - found in cache.')
    -            else :
    +                # Only display this message once per font/size
    +                if fontKey not in self._fontMsgs:
    +                    self._fontMsgs[fontKey] = True
    +                    s.log(4, F'{preMsg} from cache.')
    +            else:
                     try:
                         fontSize = s.int(fontSize)
                         self._fonts[fontKey] = ImageFont.truetype(fontPath, fontSize)
                         font = self._fonts[fontKey]
    -                    s.log(4, F'{preMsg} - loaded from disk.')
    +                    s.log(4, F'{preMsg} from disk.')
                     except OSError as err:
    -                    s.log(0, "ERROR: Font '{}' could not be loaded from disk.".format(fontPath))
    +                    self._log(0, f"ERROR: Could not load font '{fontPath}' from disk.", sendToAllsky=True)
                         font = None
             else:
                 font = None
    @@ -446,14 +531,14 @@ def _getFieldType(self,name):
         def _processText(self, name, fieldData, pilImage = None):
     
             if "format" in fieldData:
    -            format = fieldData['format']
    -            if format.startswith('%'):
    -                formatArray = format.split(',')
    +            Format = fieldData['format']
    +            if Format.startswith('%'):
    +                formatArray = Format.split(',')
                 else:
                     regex = r"\{(.*?)\}"
    -                formatArray = re.findall(regex, format)
    +                formatArray = re.findall(regex, Format)
             else:
    -            format = None
    +            Format = None
                 formatArray = {}
     
             if "empty" in fieldData:
    @@ -495,7 +580,7 @@ def _processText(self, name, fieldData, pilImage = None):
             if "tly" in fieldData:
                 fieldY = s.int(fieldData["tly"])
             else:
    -            fieldY = s.int(fieldData["x"])
    +            fieldY = s.int(fieldData["y"])
     
     
             if "fill" in fieldData:
    @@ -515,14 +600,14 @@ def _processText(self, name, fieldData, pilImage = None):
                     stroke = self._overlayConfig["settings"]["defaultstrokecolour"]
                 else:
                     stroke = '#ffffff'
    -        
    +
             if stroke != None:
                 stroker, strokeg, strokeb = self._convertColour(name, stroke)
             else:
                 stroker = None
                 strokeg = None
                 strokeb = None
    -            
    +
             r, g, b = self._convertColour(name, fieldColour)
     
             regex =  r"\$\{.*?\}"
    @@ -534,9 +619,11 @@ def _processText(self, name, fieldData, pilImage = None):
             for matchNum, match in enumerate(matches, start=1):
                 variable = match.group()
                 variableType = self._getFieldType(variable)
    +            if variableType is None and self._checkVariableType == False:
    +                variableType = self._errorType
     
                 fieldFormat = None
    -            if format is not None:
    +            if Format is not None:
                     try:
                         fieldFormat = formatArray[matchNum-1]
                     except IndexError:
    @@ -550,7 +637,6 @@ def _processText(self, name, fieldData, pilImage = None):
                         fieldEmpty = ''
     
                 fieldValue, overrideX, overrideY, overrideFill, overrideFont, overrideFontSize, overrideRotate, overrideScale, overrideOpacity, overrideStroke, overrideStrokewidth = self._getValue(variable, variableType, fieldFormat, fieldEmpty)
    -            #s.log(0, f"XXXXX: variable={variable}, fieldEmpty={fieldEmpty}, fieldValue={fieldValue}")
     
                 if overrideStroke is not None:
                     stroke = overrideStroke
    @@ -589,7 +675,6 @@ def _processText(self, name, fieldData, pilImage = None):
     
                 totalVariables += 1
     
    -        # s.log(0, f"YYXXX: totalVariables={totalVariables}, totalVariablesReplaced={totalVariablesReplaced}, fieldLabel=[{fieldLabel}]")
             # If there were variables and none matched, don't display the field.
             if totalVariables != 0 and totalVariablesReplaced == 0:
                 fieldLabel = None
    @@ -600,7 +685,7 @@ def _processText(self, name, fieldData, pilImage = None):
                     cv2.putText(self._image, fieldLabel, (fieldX,fieldY), cv2.FONT_HERSHEY_SIMPLEX, 1, (b,g,r), 1, cv2.LINE_AA)
                 else:
                     fill = (b, g, r, 0)
    -                
    +
                     if stroker == None or strokeg == None or strokeb == None:
                         strokeFill = None
                         strokeWidth = 0
    @@ -618,7 +703,7 @@ def _processText(self, name, fieldData, pilImage = None):
                     else:
                         pilImage = self._draw_rotated_text(pilImage,-rotation,(fieldX, fieldY), fieldLabel, fill = fieldColour, font = font, opacity = opacity, strokeWidth=strokeWidth, strokeFill=stroke)
     
    -            self._timer("Adding text field " + fieldLabel + ' (' + fieldData["label"] + ') ')
    +            self._timer("Adding text field '" + fieldLabel + ' (' + fieldData["label"] + ")'")
             else:
                 self._timer("Adding text field " + fieldData['label'] + " failed no variable data available")
     
    @@ -628,7 +713,7 @@ def _convertColour(self, name, value):
             try:
                 r,g,b = ImageColor.getcolor(value, "RGB")
             except:
    -            #s.log(0, f"ERROR: The colour '{value}' for field '{name}' is NOT valid - Defaulting to white.")
    +            #self._log(0, f"ERROR: The colour '{value}' for field '{name}' is NOT valid - Defaulting to white.")
                 r = 255
                 g = 255
                 b = 255
    @@ -648,12 +733,12 @@ def _checkTextBounds(self, fieldLabel, x, y):
                     outOfBounds = True
                 if (y > h):
                     outOfBounds = True
    -            
    +
                 if outOfBounds:
    -                s.log(0, f"ERROR: Field '{fieldLabel}' is outside of the image")    
    +                self._log(0, f"ERROR: Field '{fieldLabel}' is outside of the image", sendToAllsky=True)
             except:
                 pass
    -    
    +
         def get_text_dimensions(self, text_string, font):
             ascent, descent = font.getmetrics()
     
    @@ -671,7 +756,6 @@ def _convertRGBtoBGR(self, colour, opacity):
             return colour
     
         def _draw_rotated_text(self, image, angle, xy, text, fill, font, opacity, strokeWidth, strokeFill):
    -    
             fill = self._convertRGBtoBGR(fill, opacity)
             if strokeFill != "":
                 strokeFill = self._convertRGBtoBGR(strokeFill,1)
    @@ -699,7 +783,7 @@ def _doBoolFormat(self, field, value, format):
                 elif format == '%1':
                     v = '1'
                 else:
    -                s.log(0, f"ERROR: Cannot use format '{format}' on Bool variables like {field} (value={value}).")
    +                self._log(0, f"ERROR: Cannot use format '{format}' on Bool variables like {field} (value={value}).", sendToAllsky=True)
                     v = self._formaterrortext
     
             elif format == '%yes':
    @@ -711,14 +795,14 @@ def _doBoolFormat(self, field, value, format):
             elif format == '%1':
                 v = '0'
             else:
    -            s.log(0, f"ERROR: Cannot use format '{format}' on Bool variables like {field} (value={value}).")
    +            self._log(0, f"ERROR: Cannot use format '{format}' on Bool variables like {field} (value={value}).", sendToAllsky=True)
                 v = self._formaterrortext
     
             return v
     
         def _isUnixTimestamp(self, value):
             isUnixTimestamp = False
    -        isFloat = False    
    +        isFloat = False
             sanityCheckDate = time.mktime((date(2023, 1, 1)).timetuple())
     
             try:
    @@ -741,8 +825,8 @@ def _isUnixTimestamp(self, value):
                         pass
     
             return isUnixTimestamp, value
    -    
    -    def _getValue(self, placeHolder, variableType, format=None, empty=''):
    +
    +    def _getValue(self, placeHolder, variableType, Format=None, empty=''):
             value = None
             valueOk = True
             x = None
    @@ -779,7 +863,7 @@ def _getValue(self, placeHolder, variableType, format=None, empty=''):
                         fileTimeHR = fileTime.strftime("%d.%m.%y %H:%M:%S")
                         nowTime = datetime.fromtimestamp(int(time.time()))
                         nowTimeHR = nowTime.strftime("%d.%m.%y %H:%M:%S")
    -                    s.log(4, "WARNING: data field {0} expired. File time {1}, now {2}. Expiry {3} Seconds. Age {4} Seconds"
    +                    self._log(4, "WARNING: data field {0} expired. File time {1}, now {2}. Expiry {3} Seconds. Age {4} Seconds"
                             .format(placeHolder, fileTimeHR, nowTimeHR, self._extraData[envCheck]["expires"], age))
                         valueOk = False
                         expiredText = ''
    @@ -798,45 +882,49 @@ def _getValue(self, placeHolder, variableType, format=None, empty=''):
                     if envCheck in os.environ:
                         value = os.environ[envCheck]
                         fieldFound = True
    -                else:
    -                    if placeHolder.upper() in os.environ:
    -                        value = os.environ[placeHolder.upper()]
    -                        fieldFound = True
    +                elif placeHolder.upper() in os.environ:
    +                    value = os.environ[placeHolder.upper()]
    +                    fieldFound = True
     
                 if fieldFound:
                     if variableType == 'Date':
    -                    internalFormat = s.getSetting('timeformat')
    -                    if envCheck == 'DATE' or envCheck == 'AS_DATE':
    -                        timeStamp = datetime.fromtimestamp(self._imageDate)
    -                        value = timeStamp.strftime(internalFormat)
    -                    else:
    -                        isUnixTimestamp, value = self._isUnixTimestamp(value)
    -                        if isUnixTimestamp:
    -                            timeStamp = datetime.fromtimestamp(value)
    +                    try:
    +                        internalFormat = s.getSetting('timeformat')
    +                        if envCheck == 'DATE' or envCheck == 'AS_DATE':
    +                            timeStamp = datetime.fromtimestamp(self._imageDate)
                                 value = timeStamp.strftime(internalFormat)
    +                        else:
    +                            isUnixTimestamp, value = self._isUnixTimestamp(value)
    +                            if isUnixTimestamp:
    +                                timeStamp = datetime.fromtimestamp(value)
    +                                value = timeStamp.strftime(internalFormat)
     
    -                    tempDate = datetime.strptime(value, internalFormat)
    -                    if format is not None:
    -                        try:
    -                            value = tempDate.strftime(format)
    -                        except Exception:
    -                            pass  
    -                
    -                if variableType == 'Time':
    +                        tempDate = datetime.strptime(value, internalFormat)
    +                        if Format is not None:
    +                            try:
    +                                value = tempDate.strftime(Format)
    +                            except Exception:
    +                                pass
    +                    except Exception as e:
    +                        self._log(0, f"ERROR: Cannot use format '{internalFormat}' from Allsky settings. Please check the date/time format in the main Allsky Settings", sendToAllsky=True)
    +                            
    +
    +                elif variableType == 'Time':
                         if envCheck == 'AS_TIME' or envCheck == 'TIME':
                             timeStamp = time.localtime(self._imageDate)
    -                        if format is None:
    +                        if Format is None:
                                 value = time.strftime('%H:%M:%S', timeStamp)
                             else:
     			    # TODO: Check for bad format?
    -                            value = time.strftime(format, timeStamp)
    +                            value = time.strftime(Format, timeStamp)
                         else:
                             pass
    -                    
    -                if variableType == 'Number':
    -                    if format is not None and format != "":
    -                        f = format
    -                        format = "{" + format + "}"
    +                
    +                elif variableType == 'Number':
    +                    if Format is not None and Format != "":
    +                        f = Format
    +                        if Format.startswith(':'):
    +                            Format = "{" + Format + "}"
                             convertValue = 0
                             try:
                                 try:
    @@ -844,25 +932,41 @@ def _getValue(self, placeHolder, variableType, format=None, empty=''):
                                 except ValueError:
                                     convertValue = float(value)
                                 try:
    -                                value = format.format(convertValue)
    +                                if Format.startswith('{'):
    +                                    value = Format.format(convertValue)
    +                                else:
    +                                    if Format.startswith('%'):
    +                                        value = locale.format_string(Format, convertValue, grouping=True)
    +                                    else:
    +                                        value = convertValue
                                 except Exception as err:
    -                                s.log(0, f"ERROR: Cannot use format '{f}' on Number variables like {rawFieldName} (value={value}).")
    +                                self._log(0, f"ERROR: Cannot use format '{f}' on Number variables like {rawFieldName} (value={value}).", sendToAllsky=True)
                                     value = self._formaterrortext
                             except ValueError as err:
    -                            s.log(0, f"ERROR: Cannot use format '{f}' on Number variables like {rawFieldName} (value={value}).")
    +                            self._log(0, f"ERROR: Cannot use format '{f}' on Number variables like {rawFieldName} (value={value}).", sendToAllsky=True)
     
    -                if variableType == 'Bool':
    -                    if format is None or format == '':
    -                        format = "%yes"
    -                    value = self._doBoolFormat(rawFieldName, value, format)
    +                elif variableType == 'Bool':
    +                    if Format is None or Format == '':
    +                        Format = "%yes"
    +                    value = self._doBoolFormat(rawFieldName, value, Format)
     
                 if variableType == 'Text' or variableType == 'Number':
                     if value == '' or value is None:
                         if empty != '' and empty is not None:
                             value = empty
                 elif variableType is None or variableType == '':
    -                value = '???'
    -                s.log(0, f"ERROR: {rawFieldName} has no variable type; check 'userfields.json'.  Using '{value}' instead.")
    +                value = missingTypePlaceholder
    +                msg = f"ERROR: No 'Type' defined for '{rawFieldName}'; check in the Variable Manager in the WebUI's Overlay Editor page."
    +                msg += f"  Using '{value}' instead."
    +                self._log(0, msg, sendToAllsky=True)
    +            elif variableType == self._errorType:
    +                if self._displayedTypeError == False:
    +                    value = 'OVERLAY ERROR: See WebUI'
    +                    # Only display once
    +                    self._displayedTypeError = True
    +                else:
    +                    # Don't display the value so the error message above is easier to see.
    +                    value = ""
     
             return value, x, y, fill, font, fontsize, rotate, scale, opacity, stroke, strokewidth
     
    @@ -888,11 +992,10 @@ def _doAddImage(self, imageData):
                 imageY = int(imageData["y"])
                 image = None
     
    -            imagePath = os.path.join(os.environ['ALLSKY_OVERLAY'], "images", imageName)
    +            imagePath = os.path.join(s.ALLSKY_OVERLAY, "images", imageName)
     
                 if s.isFileReadable(imagePath):
                     image = cv2.imread(imagePath, cv2.IMREAD_UNCHANGED)
    -
                 if image is not None:
                     if "scale" in imageData:
                         if imageData["scale"] is not None:
    @@ -910,24 +1013,20 @@ def _doAddImage(self, imageData):
                     imageY = imageY - int(height / 2)
     
                     self._image = self._overlay_transparent(imageName, self._image, image, imageX, imageY, imageData)
    -                s.log(4, "INFO: Adding image field {}".format(imageName))
    -
    +                s.log(4, f"INFO: Adding image field {imageName}")
                 else:
    -                s.log(0, "ERROR: Cannot locate image {}.".format(imageName))
    +                self._log(1, f"WARNING: image '{imageName}' missing; ignoring.", sendToAllsky=True)
             else:
    -            s.log(0, "ERROR: Image not set so ignoring.")
    +            s.log(1, "WARNING: Image not set so ignoring.")
     
         def _overlay_transparent(self, imageName, background, overlay, x, y, imageData):
    +        background_height, background_width = background.shape[0], background.shape[1]
    +        h, w = overlay.shape[0], overlay.shape[1]
     
    -        if (overlay.shape[0] + y < background.shape[0]) and (overlay.shape[1] + x < background.shape[1] and x >= 0 and y >= 0):
    -            background_width = background.shape[1]
    -            background_height = background.shape[0]
    -
    +        if (h + y < background_height) and (w + x < background_width and x >= 0 and y >= 0):
                 if x >= background_width or y >= background_height:
                     return background
     
    -            h, w = overlay.shape[0], overlay.shape[1]
    -
                 if x + w > background_width:
                     w = background_width - x
                     overlay = overlay[:, :w]
    @@ -961,7 +1060,8 @@ def _overlay_transparent(self, imageName, background, overlay, x, y, imageData):
     
                 background[y:y+h, x:x+w] = (1.0 - mask) * background[y:y+h, x:x+w] + mask * overlay_image
             else:
    -            s.log(0, "ERROR: Image '{}' is outside the bounds of the main image.".format(imageName))
    +            self._log(0, f"ERROR: Image '{imageName}' is outside the bounds of the main image.", sendToAllsky=True)
    +
             return background
     
         def _rotate_image(self, image, angle):
    @@ -1007,22 +1107,21 @@ def _initialiseMoon(self):
                         self._moonElevation = str(round(degrees(moon.alt),2)) + u"\N{DEGREE SIGN}"
                         self._moonIllumination = str(round(moon.phase, 2))
                         self._moonPhaseSymbol  = symbol
    -                    
    -                    os.environ['AS_MOON_AZIMUTH'] = self._moonAzimuth
    -                    s.log(4, 'INFO: Adding Moon Azimuth {}'.format(self._moonAzimuth))
    -                    os.environ['AS_MOON_ELEVATION'] = self._moonElevation
    -                    s.log(4, 'INFO: Adding Moon Elevation {}'.format(self._moonElevation))
    -                    os.environ['AS_MOON_ILLUMINATION'] = self._moonIllumination
    -                    s.log(4, 'INFO: Adding Moon Illumination {}'.format(self._moonIllumination))
    -                    os.environ['AS_MOON_SYMBOL'] = self._moonPhaseSymbol
    -                    s.log(4, 'INFO: Adding Moon Symbol {}'.format(self._moonPhaseSymbol))
    +
    +                    s.log(4, 'INFO: Adding Moon Azimuth {self._moonAzimuth} and Elevation {self._moonElevation}.')
    +                    s.setEnvironmentVariable('AS_MOON_AZIMUTH', self._moonAzimuth)
    +                    s.setEnvironmentVariable('AS_MOON_ELEVATION', self._moonElevation)
    +                    s.log(4, 'INFO: Adding Moon Illumination {self._moonIllumination} and Symbol {self._moonPhaseSymbol}')
    +                    s.setEnvironmentVariable('AS_MOON_ILLUMINATION', self._moonIllumination)
    +                    s.setEnvironmentVariable('AS_MOON_SYMBOL', self._moonPhaseSymbol)
    +
                     else:
    -                    s.log(4,'INFO: Moon enabled but cannot use due to prior error.')
    +                    self._log(4,'INFO: Moon enabled but cannot use due to prior error.')
                 else:
    -                s.log(4,'INFO: Moon not enabled.')
    +                self._notEnabled = self._notEnabled + "  Moon"
             except Exception as e:
                 eType, eObject, eTraceback = sys.exc_info()
    -            s.log(0, f'ERROR: _initialiseMoon failed on line {eTraceback.tb_lineno} - {e}')            
    +            self._log(0, f'ERROR: _initialiseMoon failed on line {eTraceback.tb_lineno} - {e}')
             return True
     
         def _fileCreatedToday(self, fileName):
    @@ -1058,21 +1157,20 @@ def _getTimeZone(self):
                 file.close()
             except:
                 tz = "Europe/London"
    -        
    +
             return tz, timezone(tz)
     
         def _initialiseSun(self):
             try:
                 sunEnabled = self._overlayConfig['settings']['defaultincludesun']
                 if sunEnabled:
    -
                     lat = self._convertLatLon(self._observerLat)
                     lon = self._convertLatLon(self._observerLon)
     
    -                tzName, tz = self._getTimeZone()                
    +                tzName, tz = self._getTimeZone()
                     location = Observer(lat, lon, 0)
    -                    
    -                today = datetime.now(tz) 
    +
    +                today = datetime.now(tz)
                     tomorrow = today + timedelta(days = 1)
                     yesterday = today + timedelta(days = -1)
     
    @@ -1101,23 +1199,22 @@ def _initialiseSun(self):
                             sunset = todaySunData["sunset"]
                             dusk = todaySunData["dusk"]
     
    -                format = s.getSetting("timeformat")
    -                os.environ["AS_SUN_DAWN"] = dawn.strftime(format)
    -                os.environ["AS_SUN_SUNRISE"] = sunrise.strftime(format)
    -                os.environ["AS_SUN_NOON"] = noon.strftime(format)
    -                os.environ["AS_SUN_SUNSET"] = sunset.strftime(format)
    -                os.environ["AS_SUN_DUSK"] = dusk.strftime(format)
    +                Format = s.getSetting("timeformat")
    +                s.setEnvironmentVariable("AS_SUN_DAWN", dawn.strftime(Format))
    +                s.setEnvironmentVariable("AS_SUN_SUNRISE", sunrise.strftime(Format))
    +                s.setEnvironmentVariable("AS_SUN_NOON", noon.strftime(Format))
    +                s.setEnvironmentVariable("AS_SUN_SUNSET", sunset.strftime(Format))
    +                s.setEnvironmentVariable("AS_SUN_DUSK", dusk.strftime(Format))
    +                s.setEnvironmentVariable("AS_SUN_AZIMUTH", str(int(todaySunData["azimuth"])))
    +                s.setEnvironmentVariable("AS_SUN_ELEVATION", str(int(todaySunData["elevation"])))
     
    -                os.environ["AS_SUN_AZIMUTH"] = str(int(todaySunData["azimuth"]))
    -                os.environ["AS_SUN_ELEVATION"] = str(int(todaySunData["elevation"]))
    -
    -                s.log(4, f'INFO: Lat = {lat}, Lon = {lon}, tz = {tzName}, Sunrise = {sunrise}, Sunset = {sunset}')
    +                self._log(4, f'INFO: Lat = {lat}, Lon = {lon}, tz = {tzName}, Sunrise = {sunrise}, Sunset = {sunset}')
                 else:
    -                s.log(4,'INFO: Sun not enabled')
    +                self._notEnabled = self._notEnabled + "  Sun"
             except Exception as e:
                 eType, eObject, eTraceback = sys.exc_info()
    -            s.log(0, f'ERROR: _initialiseSun failed on line {eTraceback.tb_lineno} - {e}')
    -            
    +            self._log(0, f'ERROR: _initialiseSun failed on line {eTraceback.tb_lineno} - {e}')
    +
             return True
     
         def _initialiseSunOld(self):
    @@ -1132,10 +1229,7 @@ def _initialiseSunOld(self):
     
                     if not self._fileCreatedToday(sunTmpFile):
                         if not self._sunFast:
    -                        file = open('/etc/timezone', 'r')
    -                        tz = file.readline()
    -                        tz = tz.strip()
    -                        file.close()
    +                        notUsed, tz = self._getTimeZone()
     
                             # Figure out local midnight.
                             zone = timezone(tz)
    @@ -1179,18 +1273,21 @@ def _initialiseSunOld(self):
     
                     for key, value in cacheData.items():
                         os.environ[key] = value
    -                    s.log(4, 'INFO: Adding {0}:{1}'.format(key,value))
    +                    s.log(4, f'INFO: Adding {key}:{value}')
                 else:
    -                s.log(4,'INFO: Sun enabled but cannot use due to prior error.')
    +                s.log(4, 'INFO: Sun enabled but cannot use due to prior error.')
             else:
    -            s.log(4,'INFO: Sun not enabled.')
    +            self._notEnabled = self._notEnabled + "  sun"
     
             return True
     
         def _convertLatLon(self, input):
             """ lat and lon can either be a positive or negative float, or end with N, S, E,or W. """
             """ If in  N, S, E, W format, 0.2E becomes -0.2 """
    -        nsew = 1 if input[-1] in ['N', 'S', 'E', 'W'] else 0
    +        nsew = False
    +        if isinstance(input, str):
    +            input = input.upper()
    +            nsew = 1 if input[-1] in ['N', 'S', 'E', 'W'] else 0
             if nsew:
                 multiplier = 1 if input[-1] in ['N', 'E'] else -1
                 ret = multiplier * sum(s.asfloat(x) / 60 ** n for n, x in enumerate(input[:-1].split('-')))
    @@ -1199,7 +1296,7 @@ def _convertLatLon(self, input):
             return ret
     
         def _fetchTleFromCelestrak(self, noradCatId, verify=True):
    -        s.log(4, 'INFO: Loading Satellite {}'.format(noradCatId), True)
    +        s.log(4, f'INFO: Loading Satellite {noradCatId}', preventNewline=True)
             tleFileName = os.path.join(self._OVERLAYTLEFOLDER , noradCatId + '.tle')
     
             self._createTempDir(self._OVERLAYTLEFOLDER)
    @@ -1212,7 +1309,7 @@ def _fetchTleFromCelestrak(self, noradCatId, verify=True):
                 fileAge = 9999
     
             if fileAge > 2:
    -            r = requests.get('https://www.celestrak.com/NORAD/elements/gp.php?CATNR={}'.format(noradCatId), verify=verify, timeout=5)
    +            r = requests.get(f'https://www.celestrak.com/NORAD/elements/gp.php?CATNR={noradCatId}', verify=verify, timeout=5)
                 r.raise_for_status()
     
                 if r.text == 'No GP data found':
    @@ -1239,7 +1336,6 @@ def _fetchTleFromCelestrak(self, noradCatId, verify=True):
             return tle[0].strip(), tle[1].strip(), tle[2].strip()
     
         def _initSatellites(self):
    -        
             try:
                 satellites = self._overlayConfig["settings"]["defaultnoradids"]
                 satellites = satellites.strip()
    @@ -1264,33 +1360,35 @@ def _initSatellites(self):
                                 difference = satellite - bluffton
                                 topocentric = difference.at(t)
                                 alt, az, distance = topocentric.altaz()
    -                            os.environ['AS_' + noradId + 'ALT'] = str(alt)
    -                            os.environ['AS_' + noradId + 'AZ'] = str(az)
    +                            s.setEnvironmentVariable('AS_' + noradId + 'ALT', str(alt))
    +                            s.setEnvironmentVariable('AS_' + noradId + 'AZ', str(az))
     
                                 if alt.degrees > 5 and sunlit:
    -                                os.environ['AS_' + noradId + 'VISIBLE'] = 'Yes'
    +                                s.setEnvironmentVariable('AS_' + noradId + 'VISIBLE', 'Yes')
                                 else:
    -                                os.environ['AS_' + noradId + 'VISIBLE'] = 'No'
    +                                s.setEnvironmentVariable('AS_' + noradId + 'VISIBLE', 'No')
                             except LookupError:
    -                            s.log(0, 'ERROR: Norad ID ' + noradId + ' Not found.')
    +                            s.log(0, f'ERROR: Norad ID {noradId} Not found.')
    +                            
    +                        # Skyfield breaks the locale so reset it        
    +                        locale.setlocale(locale.LC_ALL, '')
    +                            
                     else:
    -                    s.log(4, 'INFO: Satellites enabled but cannot use due to prior error.')
    +                    self._log(4, 'INFO: Satellites enabled but cannot use due to prior error.')
     
                 else:
    -                s.log(4, 'INFO: Satellites not enabled.')
    +                self._notEnabled = self._notEnabled + "  Satellites"
     
             except Exception as e:
                 eType, eObject, eTraceback = sys.exc_info()
    -            s.log(4, ' ')
    -            s.log(0, f'ERROR: _initSatellites failed on line {eTraceback.tb_lineno} - {e}')
    -            
    +            self._log(4, ' ')
    +            self._log(0, f'ERROR: _initSatellites failed on line {eTraceback.tb_lineno} - {e}')
    +        
             return True
     
         def _initPlanets(self):
    -
             try:
                 planetsEnabled = self._overlayConfig["settings"]["defaultincludeplanets"]
    -
                 if planetsEnabled:
                     if self._enableSkyfield:
                         planets = {
    @@ -1303,10 +1401,10 @@ def _initPlanets(self):
                             'NEPTUNE BARYCENTER',
                             'PLUTO BARYCENTER'
                         }
    -                    
    +
                         timeNow = time.time()
                         utcOffset = (datetime.fromtimestamp(timeNow) - datetime.utcfromtimestamp(timeNow)).total_seconds()
    -                    
    +
                         ts = load.timescale()
                         t = ts.now() #- timedelta(seconds=utcOffset)
                         earth = self._eph['earth']
    @@ -1319,27 +1417,41 @@ def _initPlanets(self):
                             alt, az, d = astrometric.apparent().altaz()
                             ra, dec, distance = astrometric.radec()
                             #prs.int(planetId, alt, az)
    -                        os.environ['AS_' + planetId.replace(' BARYCENTER','') + 'ALT'] = str(alt)
    -                        os.environ['AS_' + planetId.replace(' BARYCENTER','') + 'AZ'] = str(az)
    -
    -                        os.environ['AS_' + planetId.replace(' BARYCENTER','') + 'RA'] = str(ra)
    -                        os.environ['AS_' + planetId.replace(' BARYCENTER','') + 'DEC'] = str(dec)
    +                        s.setEnvironmentVariable('AS_' + planetId.replace(' BARYCENTER','') + 'ALT', str(alt))
    +                        s.setEnvironmentVariable('AS_' + planetId.replace(' BARYCENTER','') + 'AZ', str(az))
    +                        s.setEnvironmentVariable('AS_' + planetId.replace(' BARYCENTER','') + 'RA', str(ra))
    +                        s.setEnvironmentVariable('AS_' + planetId.replace(' BARYCENTER','') + 'DEC', str(dec))
     
                             if alt.degrees > 5:
    -                            os.environ['AS_' + planetId.replace(' BARYCENTER','') + 'VISIBLE'] = 'Yes'
    +                            s.setEnvironmentVariable('AS_' + planetId.replace(' BARYCENTER','') + 'VISIBLE', 'Yes')
                             else:
    -                            os.environ['AS_' + planetId.replace(' BARYCENTER','') + 'VISIBLE'] = 'No'
    +                            s.setEnvironmentVariable('AS_' + planetId.replace(' BARYCENTER','') + 'VISIBLE', 'No')
                     else:
    -                    s.log(4, 'INFO: Planets enabled but unable to use due to prior error.')
    +                    self._log(4, 'INFO: Planets enabled but unable to use due to prior error.')
                 else:
    -                s.log(4, 'INFO: Planets not enabled.')
    +                self._notEnabled = self._notEnabled + "  Planets"
     
             except Exception as e:
                 eType, eObject, eTraceback = sys.exc_info()
    -            s.log(0, f'ERROR: _initPlanets failed on line {eTraceback.tb_lineno}- {e}')
    -            
    +            self._log(0, f'ERROR: _initPlanets failed on line {eTraceback.tb_lineno}- {e}')
    +
             return True
     
    +    def _addErrors(self):
    +        print(f'Errors = "{self._errors}"')
    +        if self._errors != '':
    +            h, w, c = self._image.shape
    +            fontSize = int(h * 0.015)
    +            font = self._getFont('Arial', fontSize)
    +            textWidth, textHeight = self.get_text_dimensions(self._errors, font)
    +            fieldX = (w - textWidth) // 2
    +            fieldY = 1
    +            pilImage = Image.fromarray(self._image)        
    +            draw = ImageDraw.Draw(pilImage)
    +            print(fieldX, fieldY, textWidth, textHeight, w, h, c)
    +            draw.text((fieldX, fieldY), self._errors, font = font, fill = (0,0,255))
    +            self._image = np.array(pilImage)
    +        
         def annotate(self):
             self._startTime = datetime.now()
             if self._loadConfigFile():
    @@ -1364,9 +1476,9 @@ def annotate(self):
                                     if self._loadDataFile():
                                         self._timer("Loading Extra Data")
                                         self._addText()
    -                                    self._timer("Adding Text Fields")
    +                                    self._timer("Adding All Text Fields")
                                         self._addImages()
    -                                    self._timer("Adding Image Fields")
    +                                    self._timer("Adding All Image Fields")
                                         self._saveImagefile()
                                         self._timer("Saving Final Image")
                                         if self._debug:
    @@ -1376,16 +1488,19 @@ def annotate(self):
             self._timer("Annotation Complete", showIntermediate=False)
     
     def overlay(params, event):
    +    global formatErrorPlaceholder
    +
         enabled = s.int(s.getEnvironmentVariable("AS_eOVERLAY"))
         if enabled == 1:
    -        formaterrortext = "??"
             if "formaterrortext" in params:
                 formaterrortext = params["formaterrortext"]
    +        else:
    +            formaterrortext = formatErrorPlaceholder
             annotater = ALLSKYOVERLAY(formaterrortext)
             annotater.annotate()
    -        result = "Overlay Complete"
    +        result = ""
         else:
    -        result = "External Overlay Disabled"
    +        result = "Module Overlay Method Disabled"
    +        s.log(4, f"INFO: {result}.")
     
    -    s.log(4,"INFO: {0}.".format(result))
         return result
    diff --git a/scripts/modules/allsky_pistatus.py b/scripts/modules/allsky_pistatus.py
    index 3aa6dde66..78c5a4059 100644
    --- a/scripts/modules/allsky_pistatus.py
    +++ b/scripts/modules/allsky_pistatus.py
    @@ -49,10 +49,10 @@
         '19': 'Soft temperature limit has occurred'
     }
     
    -def formatSize(bytes):
    +def formatSize(Bytes):
         try:
    -        bytes = float(bytes)
    -        kb = bytes / 1024
    +        Bytes = float(Bytes)
    +        kb = Bytes / 1024
         except:
             return "Error"
         if kb >= 1024:
    @@ -136,7 +136,7 @@ def pistatus(params, event):
         if data:
             s.saveExtraData("pistatus.json", data)
         
    -    s.log(1,'INFO: ' + result)
    +    s.log(4, f'INFO: {result}')
         return result
     
     def pistatus_cleanup():
    diff --git a/scripts/modules/allsky_saveimage.py b/scripts/modules/allsky_saveimage.py
    index 7c2113351..13a04d716 100644
    --- a/scripts/modules/allsky_saveimage.py
    +++ b/scripts/modules/allsky_saveimage.py
    @@ -42,15 +42,15 @@ def saveimage(params, event):
         quality = s.getSetting("quality")
         if quality is not None:
             quality = s.int(quality)
    -        result = "Image {0} Saved, quality {1}".format(s.CURRENTIMAGEPATH, quality)
    +        result = f"Image {s.CURRENTIMAGEPATH} Saved, quality {quality}"
     
             if not writeImage(s.image, s.CURRENTIMAGEPATH, quality):
    -            result = "Failed to save {0}".format(s.CURRENTIMAGEPATH) 
    -            s.log(0, "ERROR: Failed to save image {0}".format(s.CURRENTIMAGEPATH), exitCode=1)
    +            result = f"Failed to save {s.CURRENTIMAGEPATH}"
    +            s.log(0, f"ERROR: {result}", exitCode=1)
             else:
    -            s.log(4, "INFO: {}".format(result))
    +            s.log(4, f"INFO: {result}")
         else:
             result = "Cannot determine the image quality. Image NOT saved"
    -        s.log(0, "ERROR: {}".format(result))
    +        s.log(0, f"ERROR: {result}")
     
         return result
    diff --git a/scripts/modules/allsky_saveintermediateimage.py b/scripts/modules/allsky_saveintermediateimage.py
    index b099b230e..b9633bd85 100644
    --- a/scripts/modules/allsky_saveintermediateimage.py
    +++ b/scripts/modules/allsky_saveintermediateimage.py
    @@ -54,21 +54,21 @@ def saveintermediateimage(params, event):
         quality = s.getSetting("quality")
         if quality is not None:
             quality = s.int(quality)
    -        path = params["imagefolder"]
    -        path = s.convertPath(path)
    +        savedPath = params["imagefolder"]
    +        path = s.convertPath(savedPath)
             if path is not None:
                 path = os.path.join(path, os.path.basename(s.CURRENTIMAGEPATH))
                 if not writeImage(s.image, path, quality):
    -                result = "Failed to save {}".format(path) 
    -                s.log(0, "ERROR: Failed to save image {}".format(path))
    +                result = f"Failed to save {path}"
    +                s.log(0, f"ERROR: Failed to save image {path}")
                 else:
    -                result = "Image {} Saved".format(path)
    -                s.log(1, "INFO: {}".format(result))
    +                result = f"Image {path} Saved"
    +                s.log(1, f"INFO: {result}")
             else:
    -            result = "Invalid path {0}".format(params["imagefolder"])
    -            s.log(0, "ERROR: {}".format(result))
    +            result = "Invalid path {savedPath}"
    +            s.log(0, f"ERROR: {result}")
         else:
    -        result = "Cannot determine the image quality. Intermediate image NOT saved"
    -        s.log(0, "ERROR: {}".format(result))
    +        result = "Cannot determine the image quality. Intermediate image NOT saved."
    +        s.log(0, f"ERROR: {result}")
     
         return result
    diff --git a/scripts/modules/allsky_script.py b/scripts/modules/allsky_script.py
    index b682cc6fb..fe4c1ef26 100644
    --- a/scripts/modules/allsky_script.py
    +++ b/scripts/modules/allsky_script.py
    @@ -38,12 +38,12 @@ def script(params, event):
         if os.path.isfile(script):
             if os.access(script, os.X_OK):
                 res = subprocess.check_output(script) 
    -            result = "Script {0} Executed.".format(script)
    +            result = f"Script {script} executed."
             else:
    -            s.log(0,"ERROR: Script {0} is not executable".format(script))
    -            result = "Script {0} Is NOT Executeable.".format(script)
    +            result = f"Script {script} is NOT executeable."
    +            s.log(0, f"ERROR: {result}")
         else:
    -        s.log(0,"ERROR: cannot access {0}".format(script))
    -        result = "Script {0} Not FOund.".format(script)
    +        result = f"Script {script} not found."
    +        s.log(0, f"ERROR: {result}")
     
         return result
    diff --git a/scripts/modules/allsky_shared.py b/scripts/modules/allsky_shared.py
    index 77ee34479..fde2e98af 100644
    --- a/scripts/modules/allsky_shared.py
    +++ b/scripts/modules/allsky_shared.py
    @@ -20,6 +20,7 @@
     import locale
     import board
     import argparse
    +import locale
     
     try:
         locale.setlocale(locale.LC_ALL, '')
    @@ -28,11 +29,28 @@
     
     ABORT = True
     
    -ALLSKYPATH = None
    +def getEnvironmentVariable(name, fatal=False):
    +    result = None
    +
    +    try:
    +        result = os.environ[name]
    +    except KeyError:
    +        if fatal:
    +            log(0, f"ERROR: Environment variable '{name}' not found.", exitCode=98)
    +
    +    return result
    +
    +
    +# These must exist and are used in several places.
    +ALLSKYPATH = getEnvironmentVariable("ALLSKY_HOME", fatal=True)
    +ALLSKY_TMP = getEnvironmentVariable("ALLSKY_TMP", fatal=True)
    +ALLSKY_SCRIPTS = getEnvironmentVariable("ALLSKY_SCRIPTS", fatal=True)
    +SETTINGS_FILE = getEnvironmentVariable("SETTINGS_FILE", fatal=True)
    +ALLSKY_OVERLAY = getEnvironmentVariable("ALLSKY_OVERLAY", fatal=True)
    +
    +
     LOGLEVEL = 0
     SETTINGS = {}
    -CONFIG = {}
    -UPLOAD = {}
     TOD = ''
     DBDATA = {}
     
    @@ -58,13 +76,15 @@ def setLastRun(module):
         dbUpdate(dbKey, now)
     
     def convertLatLonOld(input):
    -    """ Converts the lat and lon from the all sky config to decimal notation i.e. 0.2E becomes -0.2"""
    +    """ Converts the lat and lon to decimal notation i.e. 0.2E becomes -0.2"""
    +    input = input.upper()
         multiplier = 1 if input[-1] in ['N', 'E'] else -1
         return multiplier * sum(float(x) / 60 ** n for n, x in enumerate(input[:-1].split('-')))
     
     def convertLatLon(input):
         """ lat and lon can either be a positive or negative float, or end with N, S, E,or W. """
         """ If in  N, S, E, W format, 0.2E becomes -0.2 """
    +    input = input.upper()
         nsew = 1 if input[-1] in ['N', 'S', 'E', 'W'] else 0
         if nsew:
             multiplier = 1 if input[-1] in ['N', 'E'] else -1
    @@ -72,12 +92,13 @@ def convertLatLon(input):
         else:
             ret = float(input)
         return ret
    -    
    +
     def skyClear():
         skyState = "unknown"
         skyStateFlag = True
    -    if "AS_SKYSTATE" in os.environ:
    -        if os.environ["AS_SKYSTATE"] == "Clear":
    +    X = getEnvironmentVariable("AS_SKYSTATE")
    +    if X is not None:
    +        if X == "Clear":
                 skyState = "clear"
                 skyStateFlag = True
             else:
    @@ -92,8 +113,9 @@ def skyClear():
     def raining():
         raining = "unknown"
         rainFlag = False
    -    if "AS_ALLSKYRAINFLAG" in os.environ:
    -        rainingFlag = os.environ["AS_ALLSKYRAINFLAG"]
    +    X = getEnvironmentVariable("AS_ALLSKYRAINFLAG")
    +    if X is not None:
    +        rainingFlag = X
             if rainingFlag == "True":
                 raining = "yes"
                 rainFlag = True
    @@ -115,12 +137,11 @@ def convertPath(path):
         matches = re.finditer(regex, path, re.MULTILINE | re.IGNORECASE)
         for matchNum, match in enumerate(matches, start=1):
             variable = match.group()
    -        envVar = variable.replace("${", "")
    -        envVar = envVar.replace("}", "")
    +        envVar = variable.replace("${", "").replace("}", "")
     
             value = None
             if envVar == "CURRENT_IMAGE":
    -            value = getEnvironmentVariable(envVar)
    +            value = getEnvironmentVariable(envVar, fatal=True)
                 value = os.path.basename(value)
             else:
                 if envVar in os.environ:
    @@ -137,111 +158,67 @@ def convertPath(path):
     
         return path
     
    +
     def startModuleDebug(module):
    -    tmpDir = getEnvironmentVariable("ALLSKY_TMP")
    -    moduleTmpDir = os.path.join(tmpDir, "debug", module)
    +    global ALLSKY_TMP
    +
    +    moduleTmpDir = os.path.join(ALLSKY_TMP, "debug", module)
         try:
             if os.path.exists(moduleTmpDir):
                 shutil.rmtree(moduleTmpDir)
             os.makedirs(moduleTmpDir, exist_ok=True)
    -        log(4,"INFO: Creating folder for debug {0}".format(moduleTmpDir))
    +        log(4, f"INFO: Creating folder for debug {moduleTmpDir}")
         except:
    -        log(0,"ERROR: Unable to create {0}".format(moduleTmpDir))
    +        log(0, f"ERROR: Unable to create {moduleTmpDir}")
    +
     
     def writeDebugImage(module, fileName, image):
    -    tmpDir = getEnvironmentVariable("ALLSKY_TMP")
    -    debugDir = os.path.join(tmpDir, "debug", module)
    +    global ALLSKY_TMP
    +
    +    debugDir = os.path.join(ALLSKY_TMP, "debug", module)
         os.makedirs(debugDir, mode = 0o777, exist_ok = True)
         moduleTmpFile = os.path.join(debugDir, fileName)
         cv2.imwrite(moduleTmpFile, image, params=None)
         log(4,"INFO: Wrote debug file {0}".format(moduleTmpFile))
     
    -def setupForCommandLine():
    -    global ALLSKYPATH, LOGLEVEL
    +
    +def setEnvironmentVariable(name, value, logMessage='', logLevel=4):
    +    result = True
     
         try:
    -        ALLSKYPATH = os.environ["ALLSKY_HOME"]
    -    except KeyError:
    -        ALLSKYPATH = "/home/pi/allsky"
    +        os.environ[name] = value
    +        if logMessage != '':
    +            log(logLevel, logMessage)
    +    except:
    +        result = False
    +        log(2, f'ERROR: Failed to set environment variable {name} to value {value}')
    +
    +    return result
    +
    +
    +def setupForCommandLine():
    +    global ALLSKYPATH
     
         command = shlex.split("bash -c 'source " + ALLSKYPATH + "/variables.sh && env'")
    -    proc = subprocess.Popen(command, stdout = subprocess.PIPE)
    +    proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
         for line in proc.stdout:
             line = line.decode(encoding='UTF-8')
             line = line.strip("\n")
             line = line.strip("\r")
             try:
                 (key, _, value) = line.partition("=")
    -            os.environ[key] = value
    +            setEnvironmentVariable(key, value)
             except Exception:
                 pass
         proc.communicate()
     
    -    readConfig()
         readSettings()
     
    -def readConfig():
    -    global CONFIG
    -
    -    if not CONFIG:
    -        log(1, "INFO: Loading and parsing config.sh")
    -        allskyConfigPath = getEnvironmentVariable("ALLSKY_CONFIG", True)
    -        allskyConfigFile = os.path.join(allskyConfigPath, "config.sh")
    -        with open(allskyConfigFile) as fp:
    -            Lines = fp.readlines()
    -            for line in Lines:
    -                if not line.startswith("#"):
    -                    if not line.startswith("if"):
    -                        line = line.strip("\n")
    -                        line = line.strip("\r")
    -                        if line:
    -                            if not line.startswith("END_OF_USER_SETTINGS"):
    -                                if "=" in line:
    -                                    try:
    -                                        (key, _, value) = line.partition("=")
    -                                        value = value.strip("\"")
    -                                        CONFIG[key] = value
    -                                    except Exception:
    -                                        pass
    -                            else:
    -                                break
    -
    -def readUploadConfig():
    -    global UPLOAD
    -
    -    if not UPLOAD:
    -        log(1, "INFO: Loading and parsing ftp-settings.sh")
    -        allskyConfigPath = getEnvironmentVariable("ALLSKY_CONFIG", True)
    -        allskyConfigFile = os.path.join(allskyConfigPath, "ftp-settings.sh")
    -        with open(allskyConfigFile) as fp:
    -            Lines = fp.readlines()
    -            for line in Lines:
    -                line = line.lstrip()
    -                if not line.startswith("#"):
    -                    if not line.startswith("if"):
    -                        line = line.strip("\n")
    -                        line = line.strip("\r")
    -                        if line:
    -                            if "=" in line:
    -                                try:
    -                                    (key, _, value) = line.partition("=")
    -                                    value = value.strip("\"")
    -                                    UPLOAD[key] = value
    -                                except Exception:
    -                                    pass
    -
    +####### settings file functions
     def readSettings():
    -    global SETTINGS
    -
    -    settingsFile = getEnvironmentVariable("SETTINGS_FILE")
    -    if settingsFile is None:
    -        camera = getEnvironmentVariable("CAMERA_TYPE")
    -        if camera is None:
    -            camera = CONFIG["CAMERA"]
    -
    -        settingsFile = os.path.join(getEnvironmentVariable("ALLSKY_CONFIG"), "settings_" + camera + ".json")
    +    global SETTINGS, SETTINGS_FILE, LOGLEVEL
     
    -    with open(settingsFile, "r") as fp:
    +    with open(SETTINGS_FILE, "r") as fp:
             SETTINGS = json.load(fp)
     
         LOGLEVEL = int(getSetting("debuglevel"))
    @@ -261,109 +238,61 @@ def getSetting(settingName):
         return result
     
     def writeSettings():
    -    global SETTINGS
    -
    -    settingsFile = getEnvironmentVariable("SETTINGS_FILE")
    -    if settingsFile is None:
    -        camera = getEnvironmentVariable("CAMERA_TYPE")
    -        if camera is None:
    -            camera = CONFIG["CAMERA"]
    -
    -        settingsFile = os.path.join(getEnvironmentVariable("ALLSKY_CONFIG"), "settings_" + camera + ".json")
    +    global SETTINGS, SETTINGS_FILE
     
    -    with open(settingsFile, "w") as fp:
    +    with open(SETTINGS_FILE, "w") as fp:
             json.dump(SETTINGS, fp, indent=4)
     
     def updateSetting(values):
    +    global SETTINGS
    +
         readSettings()
         for value in values:
             SETTINGS.update(value)
     
         writeSettings()
     
    -def getConfig(settingName):
    -    result = None
    -    try:
    -        result = CONFIG[settingName]
    -    except Exception:
    -        pass
    -
    -    return result
    -
    -def setupParams(params, metaData):
    -    readConfig()
    -
    -    for param in metaData["arguments"]:
    -        if param in metaData["argumentdetails"]:
    -            if "setting" in metaData["argumentdetails"][param]:
    -                settingKey = metaData["argumentdetails"][param]["setting"]
    -                value = getConfig(settingKey)
    -                if "type" in metaData["argumentdetails"][param]:
    -                    if "fieldtype" in metaData["argumentdetails"][param]["type"]:
    -                        type = metaData["argumentdetails"][param]["type"]["fieldtype"]
    -                        if type == "spinner":
    -                            value = int(value)
    -
    -                params[param] = value
    -
    -    return params
     
     def var_dump(variable):
         pprint.PrettyPrinter(indent=2, width=128).pprint(variable)
     
    -def setEnvironmentVariable(name, value, logMessage='', logLevel=4):
    -    result = True
    -    
    -    try:
    -        os.environ[name] = value
    -        
    -        if log != '':
    -            log(logLevel, logMessage)
    -    except:
    -        result = False
    -        log(4, f'ERROR: Failed to set environment variable {name} to value {value}')
    -        
    -    return result
    -    
    -def getEnvironmentVariable(name, fatal=False, error=''):
    -    result = None
     
    -    try:
    -        result = os.environ[name]
    -    except KeyError:
    -        if fatal:
    -            print("Sorry, environment variable ( {0} ) not found.".format(name))
    -            sys.exit(98)
    -
    -    return result
    -
    -def log(level, text, preventNewline = False, exitCode=None):
    +def log(level, text, preventNewline = False, exitCode=None, sendToAllsky=False):
         """ Very simple method to log data if in verbose mode """
    +    global LOGLEVEL, ALLSKY_SCRIPTS
    +
         if LOGLEVEL >= level:
             if preventNewline:
                 print(text, end="")
             else:
                 print(text)
     
    +    if sendToAllsky and level == 0:
    +        # Need to escape single quotes in {text}.
    +        doubleQuote = '"'
    +        text = text.replace("'", f"'{doubleQuote}'{doubleQuote}'")
    +        command = os.path.join(ALLSKY_SCRIPTS, f"addMessage.sh error '{text}'")
    +        os.system(command)
    +    
         if exitCode is not None:
             sys.exit(exitCode)
     
     def initDB():
    -    global DBDATA
    -    tmpDir = getEnvironmentVariable('ALLSKY_TMP')
    -    dbFile = os.path.join(tmpDir, 'allskydb.py')
    +    global DBDATA, ALLSKY_TMP
    +
    +    dbFile = os.path.join(ALLSKY_TMP, 'allskydb.py')
         if not os.path.isfile(dbFile):
             file = open(dbFile, 'w+')
             file.write('DataBase = {}')
             file.close()
     
         try:
    -        sys.path.insert(1, tmpDir)
    +        sys.path.insert(1, ALLSKY_TMP)
             database = __import__('allskydb')
             DBDATA = database.DataBase
         except:
             DBDATA = {}
    -        log(0, "ERROR: Allsy database corrupted - Resetting")
    +        log(0, f"ERROR: Resetting corrupted Allsky database '{dbFile}'")
     
     def dbAdd(key, value):
         global DBDATA
    @@ -380,7 +309,7 @@ def dbDeleteKey(key):
         if dbHasKey(key):
             del DBDATA[key]
             writeDB()
    -    
    +
     def dbHasKey(key):
         global DBDATA
         return (key in DBDATA)
    @@ -393,9 +322,9 @@ def dbGet(key):
             return None
     
     def writeDB():
    -    global DBDATA
    -    tmpDir = getEnvironmentVariable('ALLSKY_TMP')
    -    dbFile = os.path.join(tmpDir, 'allskydb.py')
    +    global DBDATA, ALLSKY_TMP
    +
    +    dbFile = os.path.join(ALLSKY_TMP, 'allskydb.py')
         file = open(dbFile, 'w+')
         file.write('DataBase = ')
         file.write(str(DBDATA))
    @@ -431,7 +360,7 @@ def int(val):
     
     def asfloat(val):
         localDP = ','
    -    
    +
         if not isinstance(val, str):
             val = locale.str(val)
     
    @@ -440,9 +369,30 @@ def asfloat(val):
     
         return val
     
    +
    +def getExtraDir():
    +    return getEnvironmentVariable("ALLSKY_EXTRA", fatal=True)
    +
    +def validateExtraFileName(params, module, fileKey):
    +    
    +    fileBits = os.path.splitext(params[fileKey])
    +    fileName = fileBits[0].strip()
    +    fileExtension = fileBits[1].strip()
    +    
    +    if fileExtension == '':
    +        fileExtension = '.json'
    +        
    +    if fileName == '':
    +        fileName = module
    +
    +    extraDataFilename = fileName + fileExtension
    +                    
    +    params[fileKey] = extraDataFilename
    +            
    +
     def saveExtraData(fileName, extraData):
    -    extraDataPath = getEnvironmentVariable("ALLSKY_EXTRA")
    -    if extraDataPath is not None:
    +    extraDataPath = getExtraDir()
    +    if extraDataPath is not None:               # it should never be None
             checkAndCreateDirectory(extraDataPath)
             extraDataFilename = os.path.join(extraDataPath, fileName)
             with open(extraDataFilename, "w") as file:
    @@ -450,8 +400,8 @@ def saveExtraData(fileName, extraData):
                 file.write(formattedJSON)
     
     def deleteExtraData(fileName):
    -    extraDataPath = getEnvironmentVariable("ALLSKY_EXTRA")
    -    if extraDataPath is not None:
    +    extraDataPath = getExtraDir()
    +    if extraDataPath is not None:               # it should never be None
             extraDataFilename = os.path.join(extraDataPath, fileName)
             if os.path.exists(extraDataFilename):
                 if isFileWriteable(extraDataFilename):
    @@ -466,7 +416,13 @@ def cleanupModule(moduleData):
             if "env" in moduleData["cleanup"]:
                 for envVariable in moduleData["cleanup"]["env"]:
                     os.environ.pop(envVariable, None)
    -                                
    +
    +def createTempDir(path):
    +    if not os.path.isdir(path):
    +        umask = os.umask(0o000)
    +        os.makedirs(path, mode=0o777)
    +        os.umask(umask)
    +            
     def getGPIOPin(pin):
         result = None
         if pin == 0:
    diff --git a/scripts/modules/allsky_starcount.py b/scripts/modules/allsky_starcount.py
    index 856481396..2f7aeaa5d 100644
    --- a/scripts/modules/allsky_starcount.py
    +++ b/scripts/modules/allsky_starcount.py
    @@ -161,10 +161,10 @@ def starcount(params, event):
                     image = cv2.imread(debugimage)
                     if image is None:
                         image = s.image
    -                    s.log(4, "WARNING: Debug image set to {0} but cannot be found, using latest allsky image".format(debugimage))
    +                    s.log(4, f"WARNING: Debug image set to {debugimage} but cannot be found, using latest allsky image")
                     else:
                         usingDebugImage = True
    -                    s.log(4, "WARNING: Using debug image {0}".format(debugimage))
    +                    s.log(4, f"WARNING: Using debug image {debugimage}")
                 else:
                     image = s.image
     
    @@ -181,7 +181,7 @@ def starcount(params, event):
     
                 imageMask = None
                 if mask != "":
    -                maskPath = os.path.join(s.getEnvironmentVariable("ALLSKY_OVERLAY"),"images",mask)
    +                maskPath = os.path.join(s.ALLSKY_OVERLAY, "images", mask)
                     imageMask = cv2.imread(maskPath,cv2.IMREAD_GRAYSCALE)
                     if debug:
                         s.writeDebugImage(metaData["module"], "b-image-mask.png", imageMask) 
    @@ -195,7 +195,7 @@ def starcount(params, event):
                         if debug:
                             s.writeDebugImage(metaData["module"], "h-masked-image.png", gray_image)                   
                     else:
    -                    s.log(0,"ERROR: Source image and mask dimensions do not match")
    +                    s.log(0,"ERROR: Source image and mask dimensions do not match.")
                         imageLoaded = False
     
                 detectedImageClean = gray_image.copy()
    @@ -235,20 +235,20 @@ def starcount(params, event):
                     s.writeDebugImage(metaData["module"], "final.png", image)
     
                 starCount = len(starList)
    -            os.environ["AS_STARCOUNT"] = str(starCount)
    +            s.setEnvironmentVariable("AS_STARCOUNT", str(starCount))
     
    -            result = "Total stars found {0}".format(starCount)
    -            s.log(4,"INFO: {0}".format(result))
    +            result = f"Total stars found {starCount}"
    +            s.log(4, f"INFO: {result}")
             else:
    -            result = "Sky is not clear so ignoring starcount"
    -            s.log(4,"INFO: {0}".format(result))                 
    -            os.environ["AS_STARCOUNT"] = "Disabled"
    +            result = "Sky is not clear so ignoring starcount."
    +            s.log(4, f"INFO: {result}")
    +            s.setEnvironmentVariable("AS_STARCOUNT", "Disabled")
         else:
    -        result = "Its raining so ignorning starcount"
    -        s.log(4,"INFO: {0}".format(result))
    -        os.environ["AS_STARCOUNT"] = "Disabled"
    +        result = "Its raining so ignorning starcount."
    +        s.log(4, f"INFO: {result}")
    +        s.setEnvironmentVariable("AS_STARCOUNT", "Disabled")
     
    -    return "{}".format(result)
    +    return "{result}"
     
     def starcount_cleanup():
         moduleData = {
    diff --git a/scripts/periodic.sh b/scripts/periodic.sh
    index 00ba21409..ee757b98f 100755
    --- a/scripts/periodic.sh
    +++ b/scripts/periodic.sh
    @@ -3,15 +3,13 @@
     [[ -z "${ALLSKY_HOME}" ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
     
     #shellcheck source-path=.
    -source "${ALLSKY_HOME}/variables.sh"		|| exit "${ALLSKY_ERROR_STOP}"
    +source "${ALLSKY_HOME}/variables.sh"		|| exit "${EXIT_ERROR_STOP}"
     #shellcheck source-path=scripts
    -source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${ALLSKY_ERROR_STOP}"
    -#shellcheck disable=SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/config.sh"			|| exit "${ALLSKY_ERROR_STOP}"
    +source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${EXIT_ERROR_STOP}"
     
     trap "exit 0" SIGTERM SIGINT
     
    -cd "${ALLSKY_SCRIPTS}" || exit "${ALLSKY_ERROR_STOP}"
    +cd "${ALLSKY_SCRIPTS}"						|| exit "${EXIT_ERROR_STOP}"
     
     while :
     do
    @@ -19,13 +17,14 @@ do
     	python3 "${ALLSKY_SCRIPTS}/flow-runner.py" --event periodic
     	deactivate_python_venv
     
    -    DELAY=$(jq ".periodictimer" "${ALLSKY_MODULES}/module-settings.json")
    -
    -    if [[ ! ($DELAY =~ ^[0-9]+$) ]]; then
    -        DELAY=60
    -    fi
    -    if [[ ${ALLSKY_DEBUG_LEVEL} -ge 4 ]]; then
    -		echo "INFO: Sleeping for $DELAY seconds"
    +	DELAY=$( settings ".periodictimer" "${ALLSKY_MODULES}/module-settings.json" )
    +	if [[ ! (${DELAY} =~ ^[0-9]+$) ]]; then
    +		DELAY=60
    +	fi
    +	if [[ ${ALLSKY_DEBUG_LEVEL} -ge 4 ]]; then
    +		echo "INFO: Sleeping for ${DELAY} seconds"
     	fi
    -    sleep "$DELAY"
    +	sleep "${DELAY}"
     done
    +
    +exit 0
    diff --git a/scripts/postData.sh b/scripts/postData.sh
    index f09dba630..a6274ae6e 100755
    --- a/scripts/postData.sh
    +++ b/scripts/postData.sh
    @@ -1,258 +1,215 @@
     #!/bin/bash
     
     # This script uploads a file to a Website to tell the Website when the user has defined
    -# "sunrise" and "sunset".  Use the angle set by the user.
    +# "sunrise" and "sunset".
     # A copy of the settings file is also uploaded.
    -# By default we upload to both local and remote Websites if they exist.
    +# Upload to both local and remote Websites if they are enabled.
     
     # Allow this script to be executed manually or by sudo, which requires several variables to be set.
    -[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$(realpath "$(dirname "${BASH_ARGV0}")/..")"
    -ME="$(basename "${BASH_ARGV0}")"
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
    +ME="$( basename "${BASH_ARGV0}" )"
     
    -#shellcheck disable=SC2086 source-path=.
    -source "${ALLSKY_HOME}/variables.sh"		|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086 source-path=scripts
    -source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/config.sh"			|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/ftp-settings.sh"	|| exit ${ALLSKY_ERROR_STOP}
    +#shellcheck source-path=.
    +source "${ALLSKY_HOME}/variables.sh"		|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${EXIT_ERROR_STOP}"
     
    -usage_and_exit()
    +function usage_and_exit()
     {
    -	retcode=${1}
    -	echo
    -	[[ ${retcode} -ne 0 ]] && echo -en "${RED}"
    -	echo "Usage: ${ME} [--help] [--debug] [--settingsOnly] [--websites w] [--allfiles]"
    -	[[ ${retcode} -ne 0 ]] && echo -en "${NC}"
    -	echo "    where:"
    -	echo "      '--allfiles' causes all 'view settings' files to be uploaded"
    -	# shellcheck disable=SC2086
    -	exit ${retcode}
    +	local RET=${1}
    +	{
    +		echo
    +		[[ ${RET} -ne 0 ]] && echo -en "${RED}"
    +		echo "Usage: ${ME} [--help] [--debug] [--settingsOnly] [--fromWebUI] [--allfiles]"
    +		[[ ${RET} -ne 0 ]] && echo -en "${NC}"
    +		echo "    where:"
    +		echo "      '--allfiles' causes all 'view settings' files to be uploaded"
    +	} >&2
    +	exit "${RET}"
     }
     
    +function upload_file()
    +{
    +	local WHERE="${1}"
    +	local FILE_TO_UPLOAD="${2}"
    +	local DIRECTORY="${3}"		# Directory to put file in
    +	if [[ ! -f ${FILE_TO_UPLOAD} ]]; then
    +		local MSG="File to upload '${FILE_TO_UPLOAD}' - file not found."
    +		echo -e "${RED}${ME}: ERROR: ${MSG}.${NC}" >&2
    +		if [[ ${FROM_WEBUI} == "false" ]]; then
    +			"${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${ME}: ${MSG}"
    +		fi
    +		return 1
    +	fi
    +
    +	[[ ${DEBUG} == "true" ]] && echo "Uploading ${FILE_TO_UPLOAD} to ${WHERE:-everywhere}"
    +	#shellcheck disable=SC2086
    +	upload_all ${SILENT} ${WHERE} "${FILE_TO_UPLOAD}" "${DIRECTORY}" "" "PostData"
    +	return $?
    +}
    +
    +# If called from the WebUI, it displays our output so don't call addMessage.sh.
    +FROM_WEBUI="false"
     HELP="false"
     DEBUG="false"
    -DEBUG_ARG=""
     SETTINGS_ONLY="false"
    -WEBSITES_TO_DO=""
     ALL_FILES="false"
     RET=0
     while [[ $# -gt 0 ]]; do
     	ARG="${1}"
     	case "${ARG,,}" in		# lower case
    -		--debug)
    -			DEBUG="true"
    -			DEBUG_ARG="--debug"
    -			shift
    -			;;
     		--help)
     			HELP="true"
    -			shift
     			;;
    -		--websites)
    -			WEBSITES_TO_DO="${2}"
    -			shift 2
    +		--debug)
    +			DEBUG="true"
     			;;
     		--allfiles)
     			ALL_FILES="true"
    -			shift
     			;;
     		--settingsonly)
     			SETTINGS_ONLY="true"
    -			shift
    +			;;
    +		--fromwebui)
    +			FROM_WEBUI="true"
     			;;
     		-*)
     			echo -e "${RED}Unknown argument '${ARG}'.${NC}" >&2
    -			shift
     			RET=1
     			;;
     		*)
     			break		# done with arguments
     			;;
     	esac
    +	shift
     done
     [[ ${RET} -ne 0 ]] && usage_and_exit ${RET}
    -[[ ${HELP} = "true" ]] && usage_and_exit 0
    +[[ ${HELP} == "true" ]] && usage_and_exit 0
     
    -WEBSITES="$(whatWebsites)"
    -# Make sure a local or remote Allsky Website exists.
    -if [[ ${WEBSITES} == "none" ]]; then
    -	echo -e "${YELLOW}${ME}: WARNING: No local or remote website found.${NC}"
    -	exit 0		# It's not an error
    +
    +# If there are no enabled Websites or an enabled remote server, then exit.
    +WEBS=""
    +WHERE_TO=""
    +if [[ "$( settings ".uselocalwebsite" )" == "true" ]]; then
    +	WEBS+=" --local-web"
    +	[[ -n ${WHERE_TO} ]] && WHERE_TO+=", "
    +	WHERE_TO+="local Website"
    +fi
    +if [[ "$( settings ".useremotewebsite" )" == "true" ]]; then
    +	WEBS+=" --remote-web"
    +	[[ -n ${WHERE_TO} ]] && WHERE_TO+=", "
    +	WHERE_TO+="remote Website"
    +fi
    +if [[ "$( settings ".useremoteserver" )" == "true" ]]; then
    +	[[ -n ${WHERE_TO} ]] && WHERE_TO+=", "
    +	WHERE_TO+="remote server"
     fi
     
    -[[ ${DEBUG} == "true" ]] && echo -e "${wDEBUG}WEBSITES=${WEBSITES}, WEBSITES_TO_TO=${WEBSITES_TO_DO}${NC}"
    -# Make sure we have the specified Website(s).
    -if [[ -n ${WEBSITES_TO_DO} && ${WEBSITES_TO_DO} != "${WEBSITES}" ]]; then
    -	case "${WEBSITES_TO_DO}" in
    -		local | remote | both)
    -			;;
    -		*)
    -	  		MSG="Invalid requested Website type: ${WEBSITES_TO_DO}. Must be 'local', 'remote', or 'both'"
    -			echo -e "${RED}${ME}: ERROR: ${MSG}"
    -			exit 1
    -			;;
    -	esac
    -	if [[ ( ${WEBSITES_TO_DO} == "local"  && ${WEBSITES} != "both") ||
    -		  ( ${WEBSITES_TO_DO} == "remote" && ${WEBSITES} != "both") ]]; then
    -	  	MSG="Requested Website type '${WEBSITES_TO_DO}' does not exist. Valid Websites='${WEBSITES}'."
    -		echo -e "${RED}${ME}: ERROR: ${MSG}"
    +if [[ -z ${WHERE_TO} ]]; then
    +	if [[ ${ON_TTY} == "true" ]]; then
    +		echo -e "\nWARNING: No action taken because no Websites are enabled.\n" >&2
     		exit 1
    +	else
    +		# Not on a tty so probably called via end-of-night or WebUI so silently exit.
    +		exit 0
     	fi
    -
    -	WEBSITES="${WEBSITES_TO_DO}"
     fi
     
    -if [[ ${WEBSITES} == "local" || ${WEBSITES} == "both" ]]; then
    -	HAS_LOCAL_WEBSITE="true"
    -else
    -	HAS_LOCAL_WEBSITE="false"
    -fi
    -if [[ ${WEBSITES} == "remote" || ${WEBSITES} == "both" ]]; then
    -	HAS_REMOTE_WEBSITE="true"
    +if [[ ${FROM_WEBUI} == "true" ]]; then
    +	# Don't want potentially lots of "uploading xxx" messages.
    +	SILENT="--silent"
     else
    -	HAS_REMOTE_WEBSITE="false"
    +	SILENT=""
     fi
     
     if [[ ${SETTINGS_ONLY} == "false" ]]; then
     	OK="true"
    -	if ! latitude="$(convertLatLong "$(settings ".latitude")" "latitude")" ; then
    +	if ! latitude="$( convertLatLong "$( settings ".latitude" )" "latitude" 2>&1 )" ; then
     		OK="false"
    -		echo -e "${RED}${ME}: ERROR: ${latitude}"
    -		"${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${ME}: ${latitude}"
    +		echo -e "${RED}${ME}: ERROR: ${latitude}" >&2
    +		if [[ ${FROM_WEBUI} == "false" ]]; then
    +			"${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${ME}: ${latitude}"
    +		fi
     	fi
    -	if ! longitude="$(convertLatLong "$(settings ".longitude")" "longitude")" ; then
    +	if ! longitude="$( convertLatLong "$( settings ".longitude" )" "longitude" 2>&1 )" ; then
     		OK="false"
    -		echo -e "${RED}${ME}: ERROR: ${longitude}"
    -		"${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${ME}: ${longitude}"
    +		echo -e "${RED}${ME}: ERROR: ${longitude}" >&2
    +		if [[ ${FROM_WEBUI} == "false" ]]; then
    +			"${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${ME}: ${longitude}"
    +		fi
     	fi
     	[[ ${OK} == "false" ]] && exit 1
     
    -	angle="$(settings ".angle")"
    -	timezone="$(date "+%z")"
    +	angle="$( settings ".angle" )"
    +	timezone="$( date "+%z" )"
     
     	# If nighttime happens after midnight, sunwait returns "--:-- (Midnight sun)"
     	# If nighttime happens before noon, sunwait returns "--:-- (Polar night)"
    -	sunrise="$(sunwait list rise angle "${angle}" "${latitude}" "${longitude}")"
    +	sunrise="$( sunwait list rise angle "${angle}" "${latitude}" "${longitude}" 2>&1 )"
     	sunrise_hhmm="${sunrise:0:5}"
    -	sunset="$(sunwait list set angle "${angle}" "${latitude}" "${longitude}")"
    +	sunset="$( sunwait list set angle "${angle}" "${latitude}" "${longitude}" 2>&1 )"
     	sunset_hhmm="${sunset:0:5}"
     
     	if [[ ${sunrise_hhmm} == "--:--" || ${sunset_hhmm} == "--:--" ]]; then
     		# nighttime starts after midnight or before noon.
    -		today="$(date --date='tomorrow' +%Y-%m-%d)"		# is actually tomorrow
    +		today="$( date --date='tomorrow' +%Y-%m-%d )"		# is actually tomorrow
     		# TODO What SHOULD *_hhmm be?
     		sunrise_hhmm="00:00"
     		sunset_hhmm="00:00"
     
    -		echo "***"
    -		echo -e "${RED}${ME}: WARNING: angle ${angle} caused sunwait to return"
    -		echo -e "sunrise='${sunrise}' and sunset='${sunset}'.${NC}"
    -		echo "Using tomorrow at '${sunrise_hhmm}' instead."
    -		echo "***"
    +		{
    +			echo "***"
    +			echo -e "${RED}${ME}: WARNING: angle ${angle} caused sunwait to return"
    +			echo -e "sunrise='${sunrise}' and sunset='${sunset}'.${NC}"
    +			echo "Using tomorrow at '${sunrise_hhmm}' instead."
    +			echo "***"
    +		} >&2
     	else
    -		today="$(date +%Y-%m-%d)"
    +		today="$( date +%Y-%m-%d )"
     	fi
     
    -	FILE="data.json"
    -	OUTPUT_FILE="${ALLSKY_TMP}/${FILE}"
    -	(
    -		if [[ $(settings ".takeDaytimeImages") -eq 1 ]]; then
    +	DATA_FILE="${ALLSKY_TMP}/data.json"
    +	{
    +		if [[ $( settings ".takedaytimeimages" ) == "true" ]]; then
     			D="true"
     		else
     			D="false"
     		fi
    +		if [[ $( settings ".takenighttimeimages" ) == "true" ]]; then
    +			N="true"
    +		else
    +			N="false"
    +		fi
     		echo "{"
     		echo "\"sunrise\": \"${today}T${sunrise_hhmm}:00.000${timezone}\","
     		echo "\"sunset\": \"${today}T${sunset_hhmm}:00.000${timezone}\","
    -		echo "\"streamDaytime\": \"${D}\""
    +		echo "\"streamDaytime\": \"${D}\"",		# TODO: old name - remove in next release
    +		echo "\"takedaytimeimages\": \"${D}\"",
    +		echo "\"takenighttimeimages\": \"${N}\""
     		echo "}"
    -	) > "${OUTPUT_FILE}"
    -fi
    +	} > "${DATA_FILE}"
     
    -
    -function upload_file()
    -{
    -	local FILE_TO_UPLOAD="${1}"
    -	local DIRECTORY="${2}"		# Directory to put file in
    -	if [[ ! -f ${FILE_TO_UPLOAD} ]]; then
    -		MSG="File to upload '${FILE_TO_UPLOAD}' not found."
    -		echo -e "${RED}${ME}: ERROR: ${MSG}.${NC}"
    -		"${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${ME}: ${MSG}"
    -		return 1
    -	fi
    -
    -	local RETCODE=0
    -	local S="${DIRECTORY:0:1}"
    -
    -	# Copy to local Allsky Website if it exists.
    -	if [[ ${HAS_LOCAL_WEBSITE} == "true" ]]; then
    -
    -		# If ${DIRECTORY} isn't "" and doesn't start with "/", add one.
    -		if [[ -n ${S} && ${S} != "/" ]]; then
    -			S="/"
    -		else
    -			S=""
    -		fi
    -
    -		TO="${ALLSKY_WEBSITE}${S}${DIRECTORY}"
    -		[[ ${DEBUG} == "true" ]] && echo -e "${wDEBUG}cp '${FILE_TO_UPLOAD}' '${TO}'${wNC}"
    -
    -		if ! cp "${FILE_TO_UPLOAD}" "${TO}" ; then
    -			MSG="Unable to copy '${FILE_TO_UPLOAD}' to '${ALLSKY_WEBSITE}'"
    -			echo -e "${RED}${ME}: ERROR: ${MSG}.${NC}"
    -			"${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${ME}: ${MSG}"
    -			RETCODE=1
    -		fi
    -	fi
    -
    -	# Upload to remote Website if there is one.
    -	if [[ ${HAS_REMOTE_WEBSITE} == "true" ]]; then
    -
    -		# Need a "/" to separate when both variables exist.
    -		if [[ -n ${IMAGE_DIR} ]]; then
    -			[[ -n ${S} && ${S} != "/" ]] && S="/"
    -		else
    -			S=""
    -		fi
    -
    -		# Copy relative to ${IMAGE_DIR}
    -		TO="${IMAGE_DIR}${S}${DIRECTORY}"
    -		[[ ${DEBUG} == "true" ]] && echo -e "${wDEBUG}Uploading '${FILE_TO_UPLOAD}' to ${TO:-root}${wNC}"
    -
    -		"${ALLSKY_SCRIPTS}/upload.sh" --silent ${DEBUG_ARG} \
    -			"${FILE_TO_UPLOAD}" \
    -			"${TO}" \
    -			"" \
    -			"PostData"
    -		if [[ $? -ne 0 ]]; then
    -			MSG="Unable to upload '${FILE_TO_UPLOAD}'"
    -			echo -e "${RED}${ME}: ${MSG}.${NC}"
    -			"${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${ME}: ${MSG}"
    -			RETCODE=1
    -		fi
    -	fi
    -
    -	# shellcheck disable=SC2086
    -	return ${RETCODE}
    -}
    +	# Some remote servers may want to see this file so upload everywhere.
    +	upload_file "" "${DATA_FILE}" ""		# Goes in top-level directory
    +fi
     
     # These files go in ${VIEW_DIR} so the user can display their settings.
     # This directory is in the root of the Allsky Website.
     # Assume if the first upload fails they all will, so exit.
    -upload_file "${SETTINGS_FILE}" "${ALLSKY_WEBSITE_VIEWSETTINGS_DIRECTORY_NAME}" || exit $?
    -
    -if [[ ${ALL_FILES} == "true" ]]; then
    -	upload_file "${OPTIONS_FILE}" "${ALLSKY_WEBSITE_VIEWSETTINGS_DIRECTORY_NAME}"
    -	upload_file "${ALLSKY_WEBUI}/includes/allskySettings.php" "${ALLSKY_WEBSITE_VIEWSETTINGS_DIRECTORY_NAME}"
    -	upload_file "${ALLSKY_DOCUMENTATION}/css/custom.css" "${ALLSKY_WEBSITE_VIEWSETTINGS_DIRECTORY_NAME}"
    -fi
    +if [[ -n ${WEBS} ]]; then
    +	upload_file "${WEBS}" "${SETTINGS_FILE}" "${ALLSKY_WEBSITE_VIEWSETTINGS_DIRECTORY_NAME}" || exit $?
    +
    +	if [[ ${ALL_FILES} == "true" ]]; then
    +		for file in \
    +			"${OPTIONS_FILE}" \
    +			"${ALLSKY_WEBUI}/includes/allskySettings.php" \
    +			"${ALLSKY_DOCUMENTATION}/css/custom.css" 
    +		do
    +			upload_file "${WEBS}" "${file}" "${ALLSKY_WEBSITE_VIEWSETTINGS_DIRECTORY_NAME}"
    +		done
    +	fi
     
    -if [[ ${SETTINGS_ONLY} == "false" ]]; then
    -	upload_file "${OUTPUT_FILE}" ""		# Goes in top-level directory
    -	# shellcheck disable=SC2086
    -	exit $?
    +	[[ ${FROM_WEBUI} == "true" ]] && echo "Uploaded configuration files to: ${WHERE_TO}."
     fi
     
     exit 0
    diff --git a/scripts/postToMap.sh b/scripts/postToMap.sh
    index 1a063aa1c..e33a5cc54 100755
    --- a/scripts/postToMap.sh
    +++ b/scripts/postToMap.sh
    @@ -1,25 +1,23 @@
     #!/bin/bash
     
     # Allow this script to be executed manually, which requires several variables to be set.
    -[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$(realpath "$(dirname "${BASH_ARGV0}")/..")"
    -ME="$(basename "${BASH_ARGV0}")"
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
    +ME="$( basename "${BASH_ARGV0}" )"
     
     # This script uploads various information relative to the camera setup to the allsky map.
     # https://www.thomasjacquin.com/allsky-map/
     # Information is gathered automatically from the settings file.
     # The script can be called manually, via endOfNight.sh, or via the WebUI.
     
    -#shellcheck disable=SC2086 source-path=.
    -source "${ALLSKY_HOME}/variables.sh"		|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086 source-path=scripts
    -source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/config.sh"			|| exit ${ALLSKY_ERROR_STOP}
    +#shellcheck source-path=.
    +source "${ALLSKY_HOME}/variables.sh"		|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${EXIT_ERROR_STOP}"
     
     function usage_and_exit()
     {
    -	RET_CODE=${1}
    -	[[ ${RET_CODE} -ne 0 ]] && echo -en "${wERROR}"
    +	local RET=${1}
    +	[[ ${RET} -ne 0 ]] && echo -en "${wERROR}"
     	echo
     	echo -e "Usage: ${ME} [--help] [--whisper] [--delete] [--force] [--debug] [--machineid id] [--endofnight]"
     	echo
    @@ -30,13 +28,13 @@ function usage_and_exit()
     	echo "--debug: Output debugging statements."
     	echo "--endofnight: Indicates how ${ME} was invoked."
     	echo
    -	[[ ${RET_CODE} -ne 0 ]] && echo -e "${wNC}"
    -	# shellcheck disable=SC2086
    -	exit ${RET_CODE}
    +	[[ ${RET} -ne 0 ]] && echo -e "${wNC}"
    +	exit "${RET}"
     }
     
     function get_domain()
     {
    +	local URL D
     	# Get the domain name or IP address from a URL
     	# Examples:
     	#	http://myallsky.com						# Return "myallsky.com"
    @@ -49,15 +47,17 @@ function get_domain()
     	echo "${D}"
     }
     
    -TIMEOUT=30		# seconds to wait when trying to reach a URL
     
     function check_URL()
     {
    -	URL="${1}"
    -	URL_TYPE="${2}"
    -	FIELD_NAME="${3}"
    +	local URL="${1}"
    +	local URL_TYPE="${2}"
    +	local FIELD_NAME="${3}"
    +
    +	# ${E} is a global variable we may set.
    +	local TIMEOUT=30		# seconds to wait when trying to reach a URL
     
    -	D="$(get_domain "${URL}")"
    +	local D="$( get_domain "${URL}" )"
     	if [[ "${D:0:7}"  == "192.168"		||
     		  "${D:0:4}"  == "10.0"			||
     		  "${D:0:6}"  == "172.16"		||
    @@ -72,9 +72,14 @@ function check_URL()
     		E="ERROR: ${FIELD_NAME} '${URL}' must begin with 'http:' or 'https:'.${BR}${E}"
     
     	else
    -		# Make sure it's a valid URL
    -		CONTENT="$(curl --head --silent --show-error --connect-timeout ${TIMEOUT} "${URL}" 2>&1)"
    -		RET=$?
    +		# Make sure it's a valid URL.  Some servers don't return anything if the user agent is "curl".
    +		local CONTENT="$( curl --user-agent Allsky --location --head --silent --show-error --connect-timeout ${TIMEOUT} "${URL}" 2>&1 )"
    +		local RET=$?
    +		if [[ ${DEBUG} == "true" ]]; then
    +			echo -e "\n${wDEBUG}"
    +			echo -e "check_URL(${URL}, ${URL_TYPE}, ${FIELD_NAME}) RET=${RET}:\n${CONTENT}"
    +			echo -e "${wNC}.\n"
    +		fi
     		if [[ ${RET} -eq 6 ]]; then
     			E="ERROR: ${FIELD_NAME} '${URL}' not found - check spelling and network connectivity.${BR}${E}"
     		elif [[ ${RET} -eq 28 ]]; then
    @@ -82,11 +87,12 @@ function check_URL()
     		elif [[ ${RET} -ne 0 ]]; then
     				E="ERROR: ${FIELD_NAME} '${URL}' cannot be reached (${CONTENT}).${BR}${E}"
     		else
    -			if [[ ${URL_TYPE} == "websiteurl" ]]; then
    -				TYPE="$(echo "${CONTENT}" | grep -i "Content-Type: text")"
    +			local TYPE T
    +			if [[ ${URL_TYPE} == "remotewebsiteurl" ]]; then
    +				TYPE="$( echo "${CONTENT}" | grep -i "Content-Type: text" )"
     				T="web site"
     			else
    -				TYPE="$(echo "${CONTENT}" | grep -i "Content-Type: image")"
    +				TYPE="$( echo "${CONTENT}" | grep -i "Content-Type: image" )"
     				T="image"
     			fi
     			if [[ -z ${TYPE} ]]; then
    @@ -106,7 +112,7 @@ WHISPER="false"
     ENDOFNIGHT="false"
     MACHINE_ID=""
     while [[ $# -ne 0 ]]; do
    -	case "${1}" in
    +	case "${1,,}" in
     		--help)
     			usage_and_exit 0;
     			;;
    @@ -139,19 +145,12 @@ done
     
     
     # If not on a tty, then we're either called from the endOfNight.sh script (plain text), or the WebUI (html).
    -if [[ ${ON_TTY} -eq 0 && ${ENDOFNIGHT} == "false" ]]; then
    +if [[ ${ON_TTY} == "false" && ${ENDOFNIGHT} == "false" ]]; then
     	BR="<br>"		# Line break
     else
     	BR="\n"
     fi
     
    -# shell check doesn't realize there were set in variables.sh
    -wOK="${wOK}"
    -wWARNING="${wWARNING}"
    -wERROR="${wERROR}"
    -wDEBUG="${wDEBUG}"
    -wNC="${wNC}"
    -
     if [[ ${ENDOFNIGHT} == "true" ]]; then
     	# All stdout/stderr output goes to the log file so don't include colors.
     	wERROR=""
    @@ -171,7 +170,7 @@ fi
     
     
     if [[ -z ${MACHINE_ID} ]]; then
    -	MACHINE_ID="$(< /etc/machine-id)"
    +	MACHINE_ID="$( < /etc/machine-id )"
     	if [[ -z ${MACHINE_ID} ]]; then
     		E="ERROR: Unable to get 'machine_id': check /etc/machine-id."
     		echo -e "${ERROR_MSG_START}${E}${wNC}"
    @@ -181,22 +180,21 @@ if [[ -z ${MACHINE_ID} ]]; then
     fi
     
     OK="true"
    -E=""
    -LATITUDE="$(settings ".latitude")"
    -if [[ ${LATITUDE} == "" ]]; then
    +E=""			# Global variable
    +LATITUDE="$( settings ".latitude" )"
    +if [[ -z ${LATITUDE} ]]; then
     	E="ERROR: 'Latitude' is required.${BR}${E}"
     	OK="false"
     fi
    -LONGITUDE="$(settings ".longitude")"
    -if [[ ${LONGITUDE} == "" ]]; then
    +LONGITUDE="$( settings ".longitude" )"
    +if [[ -z ${LONGITUDE} ]]; then
     	E="ERROR: 'Longitude' is required.${BR}${E}"
     	OK="false"
     fi
     [[ ${OK} == "false" ]] && echo -e "${ERROR_MSG_START}${E}${wNC}" && exit 1
     
    -OK="true"
    -LATITUDE="$(convertLatLong "${LATITUDE}" "latitude")" || OK="false"
    -LONGITUDE="$(convertLatLong "${LONGITUDE}" "longitude")" || OK="false"
    +LATITUDE="$( convertLatLong "${LATITUDE}" "latitude" )" || OK="false"
    +LONGITUDE="$( convertLatLong "${LONGITUDE}" "longitude" )" || OK="false"
     [[ ${OK} == "false" ]] && exit 1	# convertLatLong output error message
     
     if false; then
    @@ -217,35 +215,39 @@ if [[ ${DELETE} == "true" ]]; then
     	}
     
     else
    -	LOCATION="$(settings ".location")"
    -	OWNER="$(settings ".owner")"
    -	WEBSITE_URL="$(settings ".websiteurl")"
    -	IMAGE_URL="$(settings ".imageurl")"
    -	CAMERA="$(settings ".camera")"
    -	LENS="$(settings ".lens")"
    -	COMPUTER="$(settings ".computer")"
    +	LOCATION="$( settings ".location" )"
    +	OWNER="$( settings ".owner" )"
    +
    +	WEBSITE_URL="$( settings ".remotewebsiteurl" )"
    +	# Without a trailing "/" we may get a "Moved permanently" message.
    +	[[ -n ${WEBSITE_URL} && ${WEBSITE_URL: -1:1} != "/" ]] && WEBSITE_URL="${WEBSITE_URL}/"
    +
    +	IMAGE_URL="$( settings ".remotewebsiteimageurl" )"
    +	CAMERA="$( settings ".camera" )"
    +	LENS="$( settings ".lens" )"
    +	COMPUTER="$( settings ".computer" )"
     
     	OK="true"
     	E=""
     	W=""
     	# Check for required fields
    -	if [[ ${CAMERA} == "" ]]; then
    +	if [[ -z ${CAMERA} ]]; then
     		E="ERROR: 'Camera' is required.${BR}${E}"
     		OK="false"
     	fi
    -	if [[ ${COMPUTER} == "" ]]; then
    +	if [[ -z ${COMPUTER} ]]; then
     		E="ERROR: 'Computer' is required.${BR}${E}"
     		OK="false"
     	fi
     
     	# Check for optional, but suggested fields
    -	if [[ ${LOCATION} == "" ]]; then
    +	if [[ -z ${LOCATION} ]]; then
     		W="WARNING: 'Location' not set; continuing.${BR}${W}"
     	fi
    -	if [[ ${OWNER} == "" ]]; then
    +	if [[ -z ${OWNER} ]]; then
     		W="WARNING: 'Owner' not set; continuing.${BR}${W}"
     	fi
    -	if [[ ${LENS} == "" ]]; then
    +	if [[ -z ${LENS} ]]; then
     		W="WARNING: 'Lens' not set; continuing.${BR}${W}"
     	fi
     
    @@ -256,17 +258,18 @@ else
     		OK="false"
     	elif [[ -n ${WEBSITE_URL} ]]; then		# they specified both
     		# The domain names (or IP addresses) must be the same.
    -		Wurl="$(get_domain "${WEBSITE_URL}")"
    -		Iurl="$(get_domain "${IMAGE_URL}")"
    +		Wurl="$( get_domain "${WEBSITE_URL}" )"
    +		Iurl="$( get_domain "${IMAGE_URL}" )"
     		if [[ ${Wurl} != "${Iurl}" ]]; then
     			E="ERROR: The Website and Image URLs must have the same domain name or IP address.${BR}${E}"
     			OK="false"
     		fi
    +		# check_URL() may set ${E}.
     		if [[ -n ${WEBSITE_URL} ]]; then
    -			check_URL "${WEBSITE_URL}" websiteurl "Website URL" || OK="false"
    +			check_URL "${WEBSITE_URL}" remotewebsiteurl "Website URL" || OK="false"
     		fi
     		if [[ -n ${IMAGE_URL} ]]; then
    -			check_URL "${IMAGE_URL}" imageurl "Image URL" || OK="false"
    +			check_URL "${IMAGE_URL}" remotewebsiteimageurl "Image URL" || OK="false"
     		fi
     	fi
     
    @@ -274,25 +277,25 @@ else
     		echo -e "${WARNING_MSG_START}${W%%"${BR}"}${NC}"
     		# Want each message to have its own addMessage.sh entry.
     		if [[ ${ENDOFNIGHT} == "true" ]]; then
    -			echo "${W}" | while read -r w
    +			echo "${W}" | while read -r MSG
     			do
    -				"${ALLSKY_SCRIPTS}/addMessage.sh" "warning" "${ME}: ${w}"
    +				"${ALLSKY_SCRIPTS}/addMessage.sh" "warning" "${ME}: ${MSG}"
     			done
     		fi
     	fi
     	if [[ ${OK} == "false" ]]; then
     		echo -e "${ERROR_MSG_START}${E%%"${BR}"}${NC}"
     		if [[ ${ENDOFNIGHT} == "true" ]]; then
    -			echo "${E}" | while read -r e
    +			echo "${E}" | while read -r MSG
     			do
    -				"${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${ME}: ${e}"
    +				"${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${ME}: ${MSG}"
     			done
     		fi
     		exit 2
     	fi
     
     	if [[ -f ${ALLSKY_HOME}/version ]]; then
    -		ALLSKY_VERSION="$(< "${ALLSKY_HOME}/version")"
    +		ALLSKY_VERSION="$( < "${ALLSKY_HOME}/version" )"
     	else
     		ALLSKY_VERSION="unknown"		# This really should be an error
     	fi
    @@ -324,48 +327,54 @@ if [[ ${UPLOAD} == "false" ]]; then
     	digit="${MACHINE_ID: -1}"
     	decimal=$(( 16#$digit ))
     	parity="$(( decimal % 2 ))"
    -	(( $(date +%e) % 2 == parity )) && UPLOAD="true"
    +	(( $( date +%e ) % 2 == parity )) && UPLOAD="true"
     fi
     
     RETURN_CODE=0
     if [[ ${UPLOAD} == "true" ]]; then
     	if [[ ${DELETE} == "true" ]]; then
     		[[ ${WHISPER} == "false" ]] && echo "${ME}: Deleting map data."
    -	elif [[ ${ON_TTY} -eq 1 || ${ALLSKY_DEBUG_LEVEL} -ge 3 ]]; then
    +	elif [[ ${ON_TTY} == "true" || ${ALLSKY_DEBUG_LEVEL} -ge 3 ]]; then
     		[[ ${WHISPER} == "false" ]] && echo "${ME}: Uploading map data."
     	fi
     	# shellcheck disable=SC2089
    -	CMD="curl --silent -i -H 'Accept: application/json' -H 'Content-Type:application/json'"
    +	CMD="curl --silent --show-error -i -H 'Accept: application/json' -H 'Content-Type:application/json'"
     	# shellcheck disable=SC2089
    -	CMD="${CMD} --data '$(generate_post_data)' 'https://www.thomasjacquin.com/allsky-map/postToMap.php'"
    +	CMD+=" --data '$( generate_post_data )'"
    +	CMD+=" https://www.thomasjacquin.com/allsky-map/postToMap.php"
     	[[ ${DEBUG} == "true" ]] && echo -e "\n${wDEBUG}Executing:\n${CMD}${wNC}\n"
    +
     	# shellcheck disable=SC2090,SC2086
    -	RETURN="$(echo ${CMD} | bash)"
    +	RETURN="$( eval ${CMD} 2>&1 )"
     	RETURN_CODE=$?
     	[[ ${DEBUG} == "true" ]] && echo -e "\n${wDEBUG}Returned:\n${RETURN}${wNC}.\n"
     	if [[ ${RETURN_CODE} -ne 0 ]]; then
    -		E="ERROR while uploading map data with curl: ${RETURN}."
    -		echo -e "${ERROR_MSG_START}${E}${RETURN}${wNC}"
    -		[[ ${ENDOFNIGHT} == "true" ]] && "${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${ME}: ${E}"
    +		E="ERROR while uploading map data with curl: ${RETURN}, CMD=${CMD}."
    +		if [[ ${ENDOFNIGHT} == "true" ]]; then
    +			echo -e "${ME}: ${E}"		# goes in log file
    +			"${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${ME}: ${E}"
    +		else
    +			echo -e "${ERROR_MSG_START}${E}${wNC}"
    +		fi
     		exit ${RETURN_CODE}
     	fi
     
     	# Get the return string from the server.  It's the last line of output.
    -	RET="$(echo "${RETURN}" | tail -1)"
    +	RET="$( echo "${RETURN}" | tail -1 )"
     	if [[ ${RET} == "INSERTED" || ${RET} == "DELETED" ]]; then
     		echo -e "${wOK}${MSG_START}Map data ${RET}.${wNC}"
     
     	elif [[ ${RET:0:7} == "UPDATED" ]]; then
    -		echo -en "${wOK}${MSG_START}Map data UPDATED.${wNC}"
    +		[[ ${ENDOFNIGHT} == "false" ]] && echo -en "${wOK}${MSG_START}Map data UPDATED.${wNC}"
     		NUMBERS=${RET:8}	# num_updates max
     		if [[ -n ${NUMBERS} ]]; then
     			NUM_UPDATES=${NUMBERS% *}
     			MAX_UPDATES=${NUMBERS##* }
     			NUM_LEFT=$((MAX_UPDATES - NUM_UPDATES))
     			if [[ ${NUM_LEFT} -eq 0 ]]; then
    -				echo "  This is your last update allowed today."
    +				echo "  This is your last update allowed today.  You made ${MAX_UPDATES}."
     			else
    -				echo "  You can make ${NUM_LEFT} more today."
    +				echo "  You can make ${NUM_LEFT} more updates today."
     			fi
     		else
     			echo	# terminating newline
    @@ -384,8 +393,8 @@ if [[ ${UPLOAD} == "true" ]]; then
     		RETURN_CODE=2
     
     	elif [[ ${RET:0:15} == "ALREADY UPDATED" ]]; then
    -		MAX_UPDATES=${RET:17}
    -		W="NOTICE: You have already updated your map data the maximum times per day (${MAX_UPDATES}).  Try again tomorrow."
    +		MAX_UPDATES=${RET:16}
    +		W="NOTICE: You have already updated your map data the maximum of ${MAX_UPDATES} times per day.  Try again tomorrow."
     		echo -e "${WARNING_MSG_START}${W}${wNC}"
     		[[ ${ENDOFNIGHT} == "true" ]] && "${ALLSKY_SCRIPTS}/addMessage.sh" "warning" "${ME}: ${W}"
     
    @@ -396,8 +405,8 @@ if [[ ${UPLOAD} == "true" ]]; then
     		RETURN_CODE=2
     	fi
     
    -elif [[ ( ${ON_TTY} -eq 1 || ${ALLSKY_DEBUG_LEVEL} -ge 4) && ${ENDOFNIGHT} == "false"  ]]; then
    +elif [[ ( ${ON_TTY} == "true" || ${ALLSKY_DEBUG_LEVEL} -ge 4) && ${ENDOFNIGHT} == "false"  ]]; then
     	echo "${ME}: Week day doesn't match Machine ID ending - don't upload."
     fi
     
    -exit ${RETURN_CODE}
    +exit "${RETURN_CODE}"
    diff --git a/scripts/reload.sh b/scripts/reload.sh
    index 32b65773b..1f689985c 100755
    --- a/scripts/reload.sh
    +++ b/scripts/reload.sh
    @@ -8,7 +8,7 @@ if [[ -z ${PID} ]]; then
     	exit 1
     fi
     
    -TO_SEND_SIGNAL="$(pgrep --parent "${PID}")"
    +TO_SEND_SIGNAL="$( pgrep --parent "${PID}" )"
     if [[ -z ${TO_SEND_SIGNAL} ]]; then
     	echo "*** ERROR in reload.sh: cannot find any children of PID ${PID} ***"
     	exit 2
    diff --git a/scripts/removeBadImages.sh b/scripts/removeBadImages.sh
    index 40ef0d43a..40f3541fa 100755
    --- a/scripts/removeBadImages.sh
    +++ b/scripts/removeBadImages.sh
    @@ -8,77 +8,92 @@
     
     # If a file is specified, only look at that file,
     # otherwise look at all the files in the specified directory.
    -# If only 1 file, return it's MEAN.
     
    -# The MEAN is the only thing that should go to stdout.
    +ME="$( basename "${BASH_ARGV0}" )"
     
    -ME="$(basename "${BASH_ARGV0}")"
    +#shellcheck source-path=.
    +source "${ALLSKY_HOME}/variables.sh" 		|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${EXIT_ERROR_STOP}"
     
    -#shellcheck disable=SC2086 source-path=.
    -source "${ALLSKY_HOME}/variables.sh" 		|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086 source-path=scripts
    -source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086,SC1091				# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/config.sh" 		|| exit ${ALLSKY_ERROR_STOP}
    -
    -usage()
    +usage_and_exit()
     {
    -	retcode="${1}"
    -	(
    +	local RET="${1}"
    +	{
     		echo
     		echo "Remove images with corrupt data which might mess up startrails and keograms."
    -		[ "${retcode}" -ne 0 ] && echo -en "${RED}"
    +		[ "${RET}" -ne 0 ] && echo -en "${RED}"
     		echo -n "Usage: ${ME} [--help] [--debug]  directory  [file]"
    -		[ "${retcode}" -ne 0 ] && echo -e "${NC}"
    +		[ "${RET}" -ne 0 ] && echo -e "${NC}"
     		echo
    -		echo "You must enter the arguments in the above order."
    -# TODO: use getopts to allow any order
     		echo "Turning on debug will indicate bad images but will not remove them."
     		echo "If 'file' is specified, only that file in 'directory' will be checked,"
     		echo "otherwise all files in 'directory' will be checked."
    -	) >&2
    -	# shellcheck disable=SC2086
    -	exit ${retcode}
    +	} >&2
    +	exit "${RET}"
     }
    -[[ ${1} == "-h" || ${1} == "--help" ]] && usage 0
    -if [[ ${1} == "-d" || ${1} == "--debug" ]]; then
    -	DEBUG="true"
    -	r="would be removed"
    -	shift
    -else
    -	DEBUG="false"
    -	r="removed"
    -fi
     
    -[[ $# -eq 0 || $# -gt 2 ]] && usage 1
    +OK="true"
    +DO_HELP="false"
    +DEBUG="false"
    +r="removed"
    +
    +while [[ $# -gt 0 ]]; do
    +	ARG="${1}"
    +	case "${ARG,,}" in
    +			--help)
    +				DO_HELP="true"
    +				;;
    +			--debug)
    +				DEBUG="true"
    +				r="would be removed"
    +				;;
    +			-*)
    +				echo -e "${RED}${ME}: Unknown argument '${ARG}'.${NC}" >&2
    +				OK="false"
    +				;;
    +			*)
    +				break
    +				;;
    +	esac
    +	shift
    +done
    +[[ ${DO_HELP} == "true" ]] && usage_and_exit 0
    +[[ ${OK} == "false" ]] && usage_and_exit 1
    +[[ $# -eq 0 || $# -gt 2 ]] && usage_and_exit 1
     
    -DATE="${1}"
    +DIRECTORY="${1}"
     FILE="${2}"
     
     # If we're running in debug mode don't display ${ME} since it makes the output harder to read.
    -if [[ ${DEBUG} == "true" || ${ON_TTY} -eq 1 ]]; then
    +if [[ ${DEBUG} == "true" || ${ON_TTY} == "true" ]]; then
     	ME=""
     else
     	ME="${ME}:"
     fi
     
    -# If it's not a full pathname, assume it's in $ALLSKY_IMAGES.
    -[[ ${DATE:0:1} != "/" ]] && DATE="${ALLSKY_IMAGES}/${DATE}"
    -if [[ ! -d ${DATE} ]]; then
    -	echo -e "${RED}${ME} '${DATE}' is not a directory${NC}" >&2
    +# If it's not a full pathname, assume it's in ${ALLSKY_IMAGES}.
    +[[ ${DIRECTORY:0:1} != "/" ]] && DIRECTORY="${ALLSKY_IMAGES}/${DIRECTORY}"
    +if [[ ! -d ${DIRECTORY} ]]; then
    +	echo -e "${RED}${ME} '${DIRECTORY}' is not a directory${NC}" >&2
     	exit 2
     fi
     
    -if [[ ${FILE} != "" && ! -f ${DATE}/${FILE} ]]; then
    -	echo -e "${RED}${ME} '${FILE}' not found in '${DATE}'${NC}" >&2
    +if [[ ${FILE} != "" && ! -f ${DIRECTORY}/${FILE} ]]; then
    +	echo -e "${RED}${ME} '${FILE}' not found in '${DIRECTORY}'${NC}" >&2
     	exit 2
     fi
     
    -if [[ $(settings ".takeDarkFrames") -eq 1 ]]; then
    +HIGH="$( settings ".imageremovebadhigh" )"
    +LOW="$( settings ".imageremovebadlow" )"
    +if [[ $( settings ".takedarkframes" ) == "true" ]]; then
     	# Disable low brightness check since darks will have extremely low brightness.
    -	# But continue with the other checks in case the dark file is corrupted.
    -	REMOVE_BAD_IMAGES_THRESHOLD_LOW=0
    +	# Set the high value to something a dark frame should never get to.
    +	LOW=0.00000
    +	HIGH=0.01000	# 1%
     fi
    +# TODO: make BAD_LIMIT a WebUI setting.
    +BAD_LIMIT=5
     
     # Find the full size image-*jpg and image-*png files (not the thumbnails) and
     # have "convert" compute a histogram in order to capture any error messages and determine
    @@ -89,17 +104,35 @@ fi
     # sometimes produce corrupt or zero-length files.
     
     # Use IMAGE_FILES and ERROR_WORDS to avoid duplicating those strings.
    -# ${DATE} may end in a "/" so there will be "//" in the filenames, but there's no harm in that.
    -
    -set +a		# turn off auto-export since $IMAGE_FILES might be huge and produce errors
    +# ${DIRECTORY} may end in a "/" so there will be "//" in the filenames, but there's no harm in that.
    +
    +set +a		# turn off auto-export since ${IMAGE_FILES} might be huge and produce errors
    +
    +cd "${DIRECTORY}" || exit 99
    +
    +# If the LOW threshold is 0 or < 0 it's disabled.
    +# If the HIGH threshold is 0 or 1.0 (nothing can be brighter than 1.0) it's disabled.
    +if awk -v l="${LOW}" -v h="${HIGH}" '{
    +		if (l < 0) l=0;
    +		if (h > 1) h=0;
    +		if ((l + h) == 0) {
    +			exit 0;
    +		} else {
    +			exit 1;
    +		}
    +	}' ; then
    +	# Both are 0 so no checking needed.
    +	exit 0
    +fi
     
    -cd "${DATE}" || exit 99
     if [[ -n ${FILE} ]]; then
     	IMAGE_FILES="${FILE}"
     else
     	IMAGE_FILES="$( find . -maxdepth 1 -type f -iname "${FILENAME}"-\*."${EXTENSION}" )"
     fi
    -ERROR_WORDS="Huffman|Bogus|Corrupt|Invalid|Trunc|Missing|insufficient image data|no decode delegate|no images defined"
    +
    +ERROR_WORDS="Huffman|Bogus|Corrupt|Invalid|Trunc|Missing"
    +ERROR_WORDS+="|insufficient image data|no decode delegate|no images defined"
     
     # Reduce writes to disk if possible.  This script is normally called once for each file,
     # and most files are good so no output is created and hence no reason to create a temporary
    @@ -114,17 +147,9 @@ fi
     
     num_bad=0
     
    -# If the LOW threshold is 0 it's disabled.
    -# If the HIGH threshold is 0 or 100 (nothing can be brighter than 100) it's disabled.
    -# Convert possibly 0.0 and 100.0 to 0 and 100 so we can check using bash.
    -HIGH=${REMOVE_BAD_IMAGES_THRESHOLD_HIGH}
    -[[ $( echo "${HIGH} == 0 || ${HIGH} > 100" | bc ) -eq 1 ]] && HIGH=0
    -LOW=${REMOVE_BAD_IMAGES_THRESHOLD_LOW}
    -[[ $( echo "${LOW} <= 0" | bc ) -eq 1 ]] && LOW=0
    -
     # If we're processing a whole directory assume it's done in the background so "nice" it.
     # If we're only processing one file we want it done quickly.
    -if [[ ${FILE} == "" ]]; then
    +if [[ -z ${FILE} ]]; then
     		NICE="nice"
     else
     		NICE=""
    @@ -136,56 +161,64 @@ for f in ${IMAGE_FILES} ; do
     	if [[ ! -s ${f} ]]; then
     		BAD="'${f}' (zero length)"
     	else
    -		if ! MEAN=$(${NICE} convert "${f}" -colorspace Gray -format "%[fx:image.mean]" info: 2>&1) ; then
    +		if [[ -n ${AS_MEAN} ]]; then
    +			MEAN="${AS_MEAN}"		# single image: mean passed to us
    +		elif ! MEAN=$( ${NICE} convert "${f}" -colorspace Gray -format "%[fx:image.mean]" info: 2>&1 ) ; then
     			# Do NOT set BAD since this isn't necessarily a problem with the file.
     			echo -e "${RED}***${ME}: ERROR: 'convert ${f}' failed; leaving file.${NC}" >&2
     			echo -e "Message=${MEAN}" >&2
    -		elif [[ -z ${MEAN} ]]; then
    +			continue
    +		fi
    +		if [[ -z ${MEAN} ]]; then
     			# Do NOT set BAD since this isn't necessarily a problem with the file.
     			echo -e "${RED}***${ME}: ERROR: 'convert ${f}' returned nothing; leaving file.${NC}" >&2
    -		elif echo "${MEAN}" | grep -E -q "${ERROR_WORDS}"; then
    +			continue
    +		fi
    +
    +		if echo "${MEAN}" | grep -E --quiet "${ERROR_WORDS}"; then
     			# At least one error word was found in the output.
     			# Get rid of unnecessary error text, and only look at first line of error message.
    -			BAD="'${f}' (corrupt file: $(echo "${MEAN}" | sed -e 's;convert-im6.q16: ;;' -e 's; @ error.*;;' -e 's; @ warning.*;;' -e q))"
    -		else
    -			# If only one file, output its mean.
    -			[[ ${FILE} != "" ]] && echo "${MEAN}"
    +			BAD="'${f}' (corrupt file: "
    +			BAD+="$( echo "${MEAN}" | sed -e 's;convert-im6.q16: ;;' -e 's; @ error.*;;' -e 's; @ warning.*;;' -e q ))"
    +			continue
     
    +		else
     			# MEAN is a number between 0.0 and 1.0, but it may have format:
     			#	6.90319e-06
    -			# which "bc" doesn't accept.
    -			# LOW and HIGH are from 0 to 100 so first multiple the MEAN by 100
    -			# to match the LOW and HIGH.
    -			MEAN=$( echo "${MEAN}" | awk '{ printf("%0.2f", $1 * 100); }' )
    -
    -			# Since the shell doesn't do floating point math and we want the user to
    -			# be able to specify up to two digits precision,
    -			# multiple everything by 100 and convert to integer.
    +			# which "bc" doesn't accept so use awk.
    +			# LOW and HIGH are also between 0.0 and 1.0.
    +
    +			# Since the shell doesn't do floating point math and we want up to
    +			# 5 digits precision, multiple everything by 100000 and convert to integer.
    +			# We can then use bash math with the *_CHECK values.
     			# Awk handles the "e-" format.
    -			MEAN_CHECK=$( echo "${MEAN}" | awk '{ printf("%d", $1 * 100); }' )
    -			HIGH_CHECK=$( echo "${HIGH}" | awk '{ printf("%d", $1 * 100); }' )
    -			LOW_CHECK=$( echo "${LOW}" | awk '{ printf("%d", $1 * 100); }' )
    +			MEAN_CHECK=$( awk -v x="${MEAN}" '{ printf("%d", x * 100000); }' )
    +			HIGH_CHECK=$( awk -v x="${HIGH}" '{ printf("%d", x * 100000); }' )
    +			LOW_CHECK=$(  awk -v x="${LOW}"  '{ printf("%d", x * 100000); }' )
     
    +			if [[ ${DEBUG} == "true" ]]; then
    +				echo -n "${ME}: ${FILE}: MEAN=${MEAN}, MEAN_CHECK=${MEAN_CHECK},"
    +				echo " LOW_CHECK=${LOW_CHECK}, HIGH_CHECK=${HIGH_CHECK}"
    +			fi
     			MSG=""
    -
    -			if [[ ${HIGH} != "0" ]]; then		# Use the HIGH check
    -				if [[ $(echo "${MEAN_CHECK} > ${HIGH_CHECK}" | bc ) -eq 1 ]]; then
    -					BAD="'${f}' (above threshold: MEAN=${MEAN}, threshold = ${HIGH})"
    +			if [[ ${HIGH_CHECK} -ne 0 ]]; then
    +				if [[ ${MEAN_CHECK} -gt ${HIGH_CHECK} ]]; then
    +					BAD="'${f}' (MEAN of ${MEAN} is above high threshold of ${HIGH})"
     				elif [[ ${DEBUG} == "true" ]]; then
     					MSG="===== OK: ${f}, MEAN=${MEAN}, HIGH=${HIGH}, LOW=${LOW}"
     				fi
     			fi
     
     			# An image can't be both HIGH and LOW so if it was HIGH don't check for LOW.
    -			if [[ ${BAD} == "" && ${LOW} != "0" ]]; then
    -				if [[ $(echo "${MEAN_CHECK} < ${LOW_CHECK}" | bc ) -eq 1 ]]; then
    -					BAD="'${f}' (below threshold: MEAN=${MEAN}, threshold = ${LOW})"
    -				elif [[ ${DEBUG} == "true" && ${MSG} == "" ]]; then
    +			if [[ -z ${BAD} && ${LOW_CHECK} -ne 0 ]]; then
    +				if [[ ${MEAN_CHECK} -lt ${LOW_CHECK} ]]; then
    +					BAD="'${f}' (MEAN of ${MEAN} is below low threshold of ${LOW})"
    +				elif [[ ${DEBUG} == "true" && -z ${MSG} ]]; then
     					MSG="===== OK: ${f}, MEAN=${MEAN}, HIGH=${HIGH}, LOW=${LOW}"
     				fi
     			fi
     
    -			if [[ ${DEBUG} == "true" && ${BAD} == "" && -n ${MSG} ]]; then
    +			if [[ ${DEBUG} == "true" && -z ${BAD} && -n ${MSG} ]]; then
     				echo "${MSG}" >&2
     			fi
     		fi
    @@ -202,7 +235,7 @@ for f in ${IMAGE_FILES} ; do
     	fi
     done
     
    -if [[ $num_bad -eq 0 ]]; then
    +if [[ ${num_bad} -eq 0 ]]; then
     	# If only one file, "no news is good news".
     	if [[ -z ${FILE} ]]; then
     		echo -e "\n${ME} ${GREEN}No bad files found.${NC}" >&2
    @@ -218,12 +251,11 @@ else
     		echo "${ME} File is bad: ${OUTPUT}" >&2
     		echo -e "${OUTPUT}" >> "${ALLSKY_BAD_IMAGE_COUNT}"
     		BAD_COUNT="$( wc -l < "${ALLSKY_BAD_IMAGE_COUNT}" )"
    -		# TODO: make BAD_LIMIT a WebUI setting.
    -		BAD_LIMIT=3
    -# echo "xxxxxxxxxx ${BAD_COUNT} bad consecutive images" >&2
     		if [[ $((BAD_COUNT % BAD_LIMIT)) -eq 0 ]]; then
    -			MSG="Multiple bad consecutive bad images."
    -			MSG="${MSG}\nCheck 'REMOVE_BAD_IMAGES_THRESHOLD_LOW' and 'REMOVE_BAD_IMAGES_THRESHOLD_HIGH' in config.sh"
    +			MSG="Multiple consecutive bad images."
    +			MSG+="\nCheck the values of 'Remove Bad Images Threshold Low',"
    +			MSG+=" 'Remove Bad Images Threshold High',"
    +			MSG+=" and 'Max Auto-Exposure' in the WebUI."
     			"${ALLSKY_SCRIPTS}/addMessage.sh" "warning" "${MSG}" >&2
     		fi
     		if [[ ${BAD_COUNT} -ge "${BAD_LIMIT}" ]]; then
    @@ -242,7 +274,7 @@ else
     	fi
     fi
     
    -if [[ $num_bad -eq 0 ]]; then
    +if [[ ${num_bad} -eq 0 ]]; then
     	exit 0
     else
     	exit 99		# "99" means we deleted at least one file.
    diff --git a/scripts/saveImage.sh b/scripts/saveImage.sh
    index ebe04e433..6b04b9557 100755
    --- a/scripts/saveImage.sh
    +++ b/scripts/saveImage.sh
    @@ -1,25 +1,25 @@
     #!/bin/bash
     
     # Script to save a DAY or NIGHT image.
    +# It goes in ${ALLSKY_TMP} where the WebUI and local Allsky Website can find it.
     
    -ME="$(basename "${BASH_ARGV0}")"
    -
    +ME="$( basename "${BASH_ARGV0}" )"
     [[ ${ALLSKY_DEBUG_LEVEL} -ge 3 ]] && echo "${ME} $*"
     
    -#shellcheck source-path=.
    -source "${ALLSKY_HOME}/variables.sh"		|| exit "${ALLSKY_ERROR_STOP}"
    +#shellcheck disable=SC1091 source-path=.
    +source "${ALLSKY_HOME}/variables.sh"		|| exit "${EXIT_ERROR_STOP}"
     #shellcheck source-path=scripts
    -source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${ALLSKY_ERROR_STOP}"
    -#shellcheck disable=SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/config.sh"			|| exit "${ALLSKY_ERROR_STOP}"
    +source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${EXIT_ERROR_STOP}"
     
     usage_and_exit()
     {
    -	retcode=${1}
    -	[[ ${retcode} -ne 0 ]] && echo -ne "${RED}"
    -	echo -n "Usage: ${ME} DAY|NIGHT  full_path_to_image  [variable=value [...]]"
    -	[[ ${retcode} -ne 0 ]] && echo -e "${NC}"
    -	exit "${retcode}"
    +	local RET=${1}
    +	{
    +		[[ ${RET} -ne 0 ]] && echo -ne "${RED}"
    +		echo -n "Usage: ${ME} DAY|NIGHT  full_path_to_image  [variable=value [...]]"
    +		[[ ${RET} -ne 0 ]] && echo -e "${NC}"
    +	} >&2
    +	exit "${RET}"
     }
     [[ $# -lt 2 ]] && usage_and_exit 1
     
    @@ -65,47 +65,39 @@ if ! one_instance --pid-file "${PID_FILE}" --sleep "3s" --max-checks 3 \
     	exit 1
     fi
     
    -
     # The image may be in a memory filesystem, so do all the processing there and
     # leave the image used by the website(s) in that directory.
    -IMAGE_NAME=$(basename "${CURRENT_IMAGE}")	# just the file name
    -WORKING_DIR=$(dirname "${CURRENT_IMAGE}")	# the directory the image is currently in
    -
    -# Optional full check for bad images.
    -if [[ ${REMOVE_BAD_IMAGES} == "true" ]]; then
    -	# If the return code is 99, the file was bad and deleted so don't continue.
    -	AS_BAD_IMAGES_MEAN="$( "${ALLSKY_SCRIPTS}/removeBadImages.sh" "${WORKING_DIR}" "${IMAGE_NAME}" )"
    -	# removeBadImages.sh displayed error message and deleted the file.
    -	if [[ $? -eq 99 ]]; then
    -		exit 99
    -	elif [[ -n ${AS_BAD_IMAGES_MEAN} ]]; then
    -		export AS_BAD_IMAGES_MEAN
    -	fi
    -else
    -	AS_BAD_IMAGES_MEAN=""
    -fi
    -
    -# If we didn't execute removeBadImages.sh do a quick sanity check on the image.
    -# OR, if we did execute removeBaImages.sh but we're cropping the image, get the image resolution.
    -if [[ ${REMOVE_BAD_IMAGES} != "true" || ${CROP_IMAGE} == "true" ]]; then
    -	x=$(identify "${CURRENT_IMAGE}" 2>/dev/null)
    -	if [[ $? -ne 0 ]]; then
    +IMAGE_NAME=$( basename "${CURRENT_IMAGE}" )		# just the file name
    +WORKING_DIR=$( dirname "${CURRENT_IMAGE}" )		# the directory the image is currently in
    +
    +# Check for bad images.
    +# Return code 99 means the image was bad and deleted and an error message
    +# displayed so don't continue.
    +"${ALLSKY_SCRIPTS}/removeBadImages.sh" "${WORKING_DIR}" "${IMAGE_NAME}"
    +[[ $? -eq 99 ]] && exit 99
    +
    +CROP_TOP="$( settings ".imagecroptop" )"
    +CROP_RIGHT="$( settings ".imagecropright" )"
    +CROP_BOTTOM="$( settings ".imagecropbottom" )"
    +CROP_LEFT="$( settings ".imagecropleft" )"
    +CROP_IMAGE=$(( CROP_TOP + CROP_RIGHT + CROP_BOTTOM + CROP_LEFT ))		# > 0 if cropping
    +
    +# If we're cropping the image, get the image resolution.
    +if [[ ${CROP_IMAGE} -gt 0 ]]; then
    +	# Typical "identify" output:
    +	#	image.jpg JPEG 4056x3040 4056x3040+0+0 8-bit sRGB 1.19257MiB 0.000u 0:00.000
    +	if ! x=$( identify "${CURRENT_IMAGE}" 2>/dev/null ) ; then
     		echo -e "${RED}*** ${ME}: ERROR: '${CURRENT_IMAGE}' is corrupt; not saving.${NC}"
     		exit 3
     	fi
     
    -	if [[ ${CROP_IMAGE} == "true" ]]; then
    -		# Typical output:
    -			# image.jpg JPEG 4056x3040 4056x3040+0+0 8-bit sRGB 1.19257MiB 0.000u 0:00.000
    -		RESOLUTION=$(echo "${x}" | awk '{ print $3 }')
    -		# These are the resolution of the image (which may have been binned), not the sensor.
    -		RESOLUTION_X=${RESOLUTION%x*}	# everything before the "x"
    -		RESOLUTION_Y=${RESOLUTION##*x}	# everything after  the "x"
    -	fi
    +	RESOLUTION=$(echo "${x}" | awk '{ print $3 }')
    +	# These are the resolution of the image (which may have been binned), not the sensor.
    +	RESOLUTION_X=${RESOLUTION%x*}	# everything before the "x"
    +	RESOLUTION_Y=${RESOLUTION##*x}	# everything after  the "x"
     fi
     
     # Get passed-in variables.
    -# Normally at least the exposure will be passed and the sensor temp if known.
     while [[ $# -gt 0 ]]; do
     	VARIABLE="AS_${1%=*}"		# everything before the "="
     	VALUE="${1##*=}"			# everything after  the "="
    @@ -117,14 +109,11 @@ done
     # Export other variables so user can use them in overlays
     export AS_CAMERA_TYPE="${CAMERA_TYPE}"
     export AS_CAMERA_MODEL="${CAMERA_MODEL}"
    -if [[ -n ${AS_BAD_IMAGES_MEAN} ]]; then
    -	export AS_MEAN_NORMALIZED="$( echo "${AS_BAD_IMAGES_MEAN} * 255" | bc )"	# xxxx for testing
    -fi
    -
    +export AS_CAMERA_NUMBER="${CAMERA_NUMBER}"
     
     # If ${AS_TEMPERATURE_C} is set, use it as the sensor temperature,
     # otherwise use the temperature in ${TEMPERATURE_FILE}.
    -# TODO: Currently nothing creates the TEMPERATURE_FILE.  Eventually RPi cameras will.
    +# The TEMPERATURE_FILE is manually created if needed.
     if [[ -z ${AS_TEMPERATURE_C} ]]; then
     	TEMPERATURE_FILE="${ALLSKY_TMP}/temperature.txt"
     	if [[ -s ${TEMPERATURE_FILE} ]]; then	# -s so we don't use an empty file
    @@ -133,7 +122,7 @@ if [[ -z ${AS_TEMPERATURE_C} ]]; then
     fi
     
     # If taking dark frames, save the dark frame then exit.
    -if [[ $(settings ".takeDarkFrames") -eq 1 ]]; then
    +if [[ $( settings ".takedarkframes" ) == "true" ]]; then
     	#shellcheck source-path=scripts
     	source "${ALLSKY_SCRIPTS}/darkCapture.sh"
     	exit 0
    @@ -141,10 +130,11 @@ fi
     
     # TODO: Dark subtract long-exposure images, even if during daytime.
     # TODO: Need a config variable to specify the threshold to dark subtract.
    -# TODO: Possibly also for stretching below.
    -if [[ ${DAY_OR_NIGHT} == "NIGHT" ]]; then
    -	#shellcheck source-path=scripts
    -	source "${ALLSKY_SCRIPTS}/darkSubtract.sh"	# It will modify the image but not its name.
    +if [[ $( settings ".usedarkframes" ) == "true" ]]; then
    +	if [[ ${DAY_OR_NIGHT} == "NIGHT" ]]; then
    +		#shellcheck source-path=scripts
    +		source "${ALLSKY_SCRIPTS}/darkSubtract.sh"	# It will modify the image but not its name.
    +	fi
     fi
     
     # If any of the "convert"s below fail, exit since we won't know if the file was corrupted.
    @@ -165,71 +155,65 @@ function display_error_and_exit()	# error message, notification string
     		"*** ERROR ***\nAllsky Stopped!\nInvalid ${NOTIFICATION_STRING} settings\nSee\n/var/log/allsky.log"
     
     	# Don't let the service restart us because we will get the same error again.
    -	sudo systemctl stop allsky
    -	# shellcheck disable=SC2086
    -	exit ${EXIT_ERROR_STOP}
    +	stop_Allsky
    +	set_allsky_status "${ALLSKY_STATUS_ERROR}"
    +	exit "${EXIT_ERROR_STOP}"
     }
     
     # Resize the image if required
    -if [[ ${IMG_RESIZE} == "true" ]] ; then
    +RESIZE_W="$( settings ".imageresizewidth" )"
    +RESIZE_H="$( settings ".imageresizeheight" )"
    +export AS_RESIZE_WIDTH="${RESIZE_W}"
    +export AS_RESIZE_HEIGHT="${RESIZE_H}"
    +if [[ ${RESIZE_W} -gt 0 && ${RESIZE_H} -gt 0 ]]; then
     	# Make sure we were given numbers.
     	ERROR_MSG=""
    -	if [[ ${IMG_WIDTH} != +([+0-9]) ]]; then		# no negative numbers allowed
    -		ERROR_MSG="${ERROR_MSG}\nIMG_WIDTH (${IMG_WIDTH}) must be a number."
    +	if [[ ${RESIZE_W} != +([+0-9]) ]]; then		# no negative numbers allowed
    +		ERROR_MSG+="\n'Image Resize Height' (${RESIZE_W}) must be a number."
     	fi
    -	if [[ ${IMG_WIDTH} != +([+0-9]) ]]; then
    -		ERROR_MSG="${ERROR_MSG}\nIMG_HEIGHT (${IMG_HEIGHT}) must be a number."
    +	if [[ ${RESIZE_H} != +([+0-9]) ]]; then
    +		ERROR_MSG+="\n'Image Resize Width' (${RESIZE_H}) must be a number."
     	fi
     	if [[ -n ${ERROR_MSG} ]]; then
     		echo -e "${RED}*** ${ME}: ERROR: Image resize number(s) invalid.${NC}"
    -		display_error_and_exit "${ERROR_MSG}" "IMG_RESIZE"
    +		display_error_and_exit "${ERROR_MSG}" "Image Resize"
     	fi
     
    -	[[ ${ALLSKY_DEBUG_LEVEL} -ge 4 ]] && echo "${ME}: Resizing '${CURRENT_IMAGE}' to ${IMG_WIDTH}x${IMG_HEIGHT}"
    -	if ! convert "${CURRENT_IMAGE}" -resize "${IMG_WIDTH}x${IMG_HEIGHT}" "${CURRENT_IMAGE}" ; then
    -		echo -e "${RED}*** ${ME}: ERROR: IMG_RESIZE failed; not saving${NC}"
    -		exit 4
    +	if [[ ${ALLSKY_DEBUG_LEVEL} -ge 3 ]]; then
    +		echo "${ME}: Resizing '${CURRENT_IMAGE}' to ${RESIZE_W}x${RESIZE_H}"
     	fi
    -fi
    -
    -# Crop the image if required
    -if [[ ${CROP_IMAGE} == "true" ]]; then
    -	# If the image was just resized, the resolution changed, so reset the variables.
    -	if [[ ${IMG_RESIZE} == "true" ]]; then
    -		RESOLUTION_X=${IMG_WIDTH}
    -		RESOLUTION_Y=${IMG_HEIGHT}
    -	fi
    -
    -	# Do some sanity checks on the CROP_* variables.
    -	ERROR_MSG=""
    -	# shellcheck disable=SC2153
    -	if ! E="$(checkPixelValue "CROP_WIDTH" "${CROP_WIDTH}" "width" "${RESOLUTION_X}")" ; then
    -		ERROR_MSG="${ERROR_MSG}\n${E}"
    -	fi
    -	# shellcheck disable=SC2153
    -	if ! E="$(checkPixelValue "CROP_HEIGHT" "${CROP_HEIGHT}" "height" "${RESOLUTION_Y}")"; then
    -		ERROR_MSG="${ERROR_MSG}\n${E}"
    -	fi
    -	if ! E="$(checkPixelValue "CROP_OFFSET_X" "${CROP_OFFSET_X}" "width" "${RESOLUTION_X}" "any")" ; then
    -		ERROR_MSG="${ERROR_MSG}\n${E}"
    -	fi
    -	if ! E="$(checkPixelValue "CROP_OFFSET_Y" "${CROP_OFFSET_Y}" "height" "${RESOLUTION_Y}" "any")" ; then
    -		ERROR_MSG="${ERROR_MSG}\n${E}"
    +	if ! convert "${CURRENT_IMAGE}" -resize "${RESIZE_W}x${RESIZE_H}" "${CURRENT_IMAGE}" ; then
    +		echo -e "${RED}*** ${ME}: ERROR: image resize failed; not saving${NC}"
    +		exit 4
     	fi
     
    -	# Now for more intensive checks.
    -	if [[ -z ${ERROR_MSG} ]]; then
    -		ERROR_MSG="$(checkCropValues "${CROP_WIDTH}" "${CROP_HEIGHT}" \
    -			"${CROP_OFFSET_X}" "${CROP_OFFSET_Y}" \
    -			"${RESOLUTION_X}" "${RESOLUTION_Y}")"
    +	if [[ ${CROP_IMAGE} -gt 0 ]]; then
    +		# The image was just resized and the resolution changed, so reset the variables.
    +		RESOLUTION_X=${RESIZE_W}
    +		RESOLUTION_Y=${RESIZE_H}
     	fi
    +fi
     
    +# Crop the image if required
    +if [[ ${CROP_IMAGE} -gt 0 ]]; then
    +	# Perform basic checks on crop settings.
    +	ERROR_MSG="$( checkCropValues "${CROP_TOP}" "${CROP_RIGHT}" "${CROP_BOTTOM}" "${CROP_LEFT}" \
    +		"${RESOLUTION_X}" "${RESOLUTION_Y}" 2>&1 )"
     	if [[ -z ${ERROR_MSG} ]]; then
    -		if [[ ${ALLSKY_DEBUG_LEVEL} -ge 4 ]]; then
    +		if [[ ${ALLSKY_DEBUG_LEVEL} -ge 3 ]]; then
    +			CROP_WIDTH=$(( RESOLUTION_X - CROP_RIGHT - CROP_LEFT ))
    +			CROP_HEIGHT=$(( RESOLUTION_Y - CROP_TOP - CROP_BOTTOM ))
     			echo -e "${ME} Cropping '${CURRENT_IMAGE}' to ${CROP_WIDTH}x${CROP_HEIGHT}."
     		fi
    -		convert "${CURRENT_IMAGE}" -gravity Center -crop "${CROP_WIDTH}x${CROP_HEIGHT}+${CROP_OFFSET_X}+${CROP_OFFSET_Y}" +repage "${CURRENT_IMAGE}"
    -		if [ $? -ne 0 ] ; then
    +		C=""
    +		[[ ${CROP_TOP} -ne 0 ]] && C+=" -gravity North -chop 0x${CROP_TOP}"
    +		[[ ${CROP_RIGHT} -ne 0 ]] && C+=" -gravity East -chop ${CROP_RIGHT}x0"
    +		[[ ${CROP_BOTTOM} -ne 0 ]] && C+=" -gravity South -chop 0x${CROP_BOTTOM}"
    +		[[ ${CROP_LEFT} -ne 0 ]] && C+=" -gravity West -chop ${CROP_LEFT}x0"
    +
    +		# shellcheck disable=SC2086
    +		convert "${CURRENT_IMAGE}" ${C} "${CURRENT_IMAGE}"
    +		if [[ $? -ne 0 ]] ; then
     			echo -e "${RED}*** ${ME}: ERROR: CROP_IMAGE failed; not saving${NC}"
     			exit 4
     		fi
    @@ -239,25 +223,30 @@ if [[ ${CROP_IMAGE} == "true" ]]; then
     	fi
     fi
     
    -# Stretch the image if required, but only at night.
    -if [[ ${DAY_OR_NIGHT} == "NIGHT" && ${AUTO_STRETCH} == "true" ]]; then
    -	if [[ ${ALLSKY_DEBUG_LEVEL} -ge 4 ]]; then
    -		echo "${ME}: Stretching '${CURRENT_IMAGE}' by ${AUTO_STRETCH_AMOUNT}"
    +# Stretch the image if required.
    +STRETCH_AMOUNT="$( settings ".imagestretchamount${DAY_OR_NIGHT,,}time" )"
    +STRETCH_MIDPOINT="$( settings ".imagestretchmidpoint${DAY_OR_NIGHT,,}time" )"
    +export AS_STRETCH_AMOUNT="${STRETCH_AMOUNT}"
    +export AS_STRETCH_MIDPOINT="${STRETCH_MIDPOINT}"
    +if [[ ${STRETCH_AMOUNT} -gt 0 ]]; then
    +	if [[ ${ALLSKY_DEBUG_LEVEL} -ge 3 ]]; then
    +		echo "${ME}: Stretching '${CURRENT_IMAGE}' by ${STRETCH_AMOUNT} @ ${STRETCH_MIDPOINT}%"
     	fi
    - 	convert "${CURRENT_IMAGE}" -sigmoidal-contrast "${AUTO_STRETCH_AMOUNT}x${AUTO_STRETCH_MID_POINT}" "${CURRENT_IMAGE}"
    -	if [ $? -ne 0 ] ; then
    + 	convert "${CURRENT_IMAGE}" -sigmoidal-contrast "${STRETCH_AMOUNT}x${STRETCH_MIDPOINT}%" "${CURRENT_IMAGE}"
    +
    +	if [[ $? -ne 0 ]]; then
     		echo -e "${RED}*** ${ME}: ERROR: AUTO_STRETCH failed; not saving${NC}"
     		exit 4
     	fi
     fi
     
    -if [ "${DAY_OR_NIGHT}" = "NIGHT" ] ; then
    +if [[ "${DAY_OR_NIGHT}" == "NIGHT" ]]; then
     	# The 12 hours ago option ensures that we're always using today's date
     	# even at high latitudes where civil twilight can start after midnight.
    -	export DATE_NAME="$(date -d '12 hours ago' +'%Y%m%d')"
    +	export DATE_NAME="$( date -d '12 hours ago' +'%Y%m%d' )"
     else
     	# During the daytime we alway save the file in today's directory.
    -	export DATE_NAME="$(date +'%Y%m%d')"
    +	export DATE_NAME="$( date +'%Y%m%d' )"
     fi
     
     activate_python_venv
    @@ -272,8 +261,10 @@ rm -f "${PID_FILE}"
     SAVED_FILE="${CURRENT_IMAGE}"						# The name of the file saved from the camera.
     WEBSITE_FILE="${WORKING_DIR}/${FULL_FILENAME}"		# The name of the file the websites look for
     
    +TIMELAPSE_MINI_UPLOAD_VIDEO="$( settings ".minitimelapseupload" )"
     # If needed, save the current image in today's directory.
    -if [[ $(settings ".saveDaytimeImages") -eq 1 || ${DAY_OR_NIGHT} == "NIGHT" ]]; then
    +if [[ ( $( settings ".savedaytimeimages" ) == "true" && ${DAY_OR_NIGHT} == "DAY" ) || 
    +	  ( $( settings ".savenighttimeimages" ) == "true" && ${DAY_OR_NIGHT} == "NIGHT" ) ]]; then
     	SAVE_IMAGE="true"
     else
     	SAVE_IMAGE="false"
    @@ -283,22 +274,23 @@ if [[ ${SAVE_IMAGE} == "true" ]]; then
     	if [[ ${DAY_OR_NIGHT} == "NIGHT" ]]; then
     		# The 12 hours ago option ensures that we're always using today's date
     		# even at high latitudes where civil twilight can start after midnight.
    -		DATE_NAME="$(date -d '12 hours ago' +'%Y%m%d')"
    +		DATE_NAME="$( date -d '12 hours ago' +'%Y%m%d' )"
     	else
     		# During the daytime we alway save the file in today's directory.
    -		DATE_NAME="$(date +'%Y%m%d')"
    +		DATE_NAME="$( date +'%Y%m%d' )"
     	fi
     	DATE_DIR="${ALLSKY_IMAGES}/${DATE_NAME}"
    -	mkdir -p "${DATE_DIR}"
    +	[[ ! -d ${DATE_DIR} ]] && mkdir -p "${DATE_DIR}"
     
    -	if [[ ${IMG_CREATE_THUMBNAILS} == "true" ]]; then
    +	if [[ $( settings ".imagecreatethumbnails" ) == "true" ]]; then
     		THUMBNAILS_DIR="${DATE_DIR}/thumbnails"
     		mkdir -p "${THUMBNAILS_DIR}"
     		# Create a thumbnail of the image for faster load in the WebUI.
     		# If we resized above, this will be a resize of a resize,
     		# but for thumbnails that should be ok.
    -		convert "${CURRENT_IMAGE}" -resize "${THUMBNAIL_SIZE_X}x${THUMBNAIL_SIZE_Y}" "${THUMBNAILS_DIR}/${IMAGE_NAME}"
    -		if [ $? -ne 0 ] ; then
    +		X="$( settings ".thumbnailsizex" )"
    +		Y="$( settings ".thumbnailsizey" )"
    +		if ! convert "${CURRENT_IMAGE}" -resize "${X}x${Y}" "${THUMBNAILS_DIR}/${IMAGE_NAME}" ; then
     			echo -e "${YELLOW}*** ${ME}: WARNING: THUMBNAIL resize failed; continuing.${NC}"
     		fi
     	fi
    @@ -308,9 +300,19 @@ if [[ ${SAVE_IMAGE} == "true" ]]; then
     	FINAL_FILE="${DATE_DIR}/${IMAGE_NAME}"
     	if cp "${CURRENT_IMAGE}" "${FINAL_FILE}" ; then
     
    -		if [[ ${TIMELAPSE_MINI_IMAGES} -ne 0 && ${TIMELAPSE_MINI_FREQUENCY} -ne 1 ]]; then
    +		TIMELAPSE_MINI_IMAGES="$( settings ".minitimelapsenumimages" )"
    +		TIMELAPSE_MINI_FREQUENCY="$( settings ".minitimelapsefrequency" )"
    +		if [[ ${TIMELAPSE_MINI_IMAGES} -eq 0 ]]; then
    +			TIMELAPSE_MINI_UPLOAD_VIDEO="false"
    +
    +		elif [[ ${TIMELAPSE_MINI_FREQUENCY} -ne 1 ]]; then
    +			TIMELAPSE_MINI_FORCE_CREATION="$( settings ".minitimelapseforcecreation" )"
     			# We are creating mini-timelapses; see if we should create one now.
     
    +			CREATE="false"
    +			MOD=0
    +
    +			# See how many images we have and how many are left.
     			MINI_TIMELAPSE_FILES="${ALLSKY_TMP}/mini-timelapse_files.txt"	 # List of files
     			if [[ ! -f ${MINI_TIMELAPSE_FILES} ]]; then
     				# The file may have been deleted for an unknown reason.
    @@ -324,67 +326,57 @@ if [[ ${SAVE_IMAGE} == "true" ]]; then
     					# This shouldn't happen...
     					echo -e "${YELLOW}${ME} WARNING: '${FINAL_FILE}' already in set.${NC}" >&2
     				fi
    -				NUM_IMAGES=$(wc -l < "${MINI_TIMELAPSE_FILES}")
    +				NUM_IMAGES=$( wc -l < "${MINI_TIMELAPSE_FILES}" )
     				LEFT=$((TIMELAPSE_MINI_IMAGES - NUM_IMAGES))
    -			fi
    -			[[ ${ALLSKY_DEBUG_LEVEL} -ge 4 ]] && echo -e "NUM_IMAGES=${NUM_IMAGES}" >&2
    +				MOD="$( echo "${NUM_IMAGES} % ${TIMELAPSE_MINI_FREQUENCY}" | bc )"
     
    -			MOD=0
    -			if [[ ${TIMELAPSE_MINI_FORCE_CREATION} == "true" ]]; then
    -				# We only force creation every${TIMELAPSE_MINI_FREQUENCY} images,
    -				# and only when we haven't reached ${TIMELAPSE_MINI_IMAGES} or we're close.
    -				if [[ ${LEFT} -lt ${TIMELAPSE_MINI_FREQUENCY} ]]; then
    -					TIMELAPSE_MINI_FORCE_CREATION="false"
    -				else
    -					MOD="$(echo "${NUM_IMAGES} % ${TIMELAPSE_MINI_FREQUENCY}" | bc)"
    -					[[ ${MOD} -ne 0 ]] && TIMELAPSE_MINI_FORCE_CREATION="false"
    +				# If either of the following are true we'll create the mini-timelapse:
    +				#	1. We have ${TIMELAPSE_MINI_IMAGES}  (i.e., ${LEFT} -eq 0)
    +				#	2. ${TIMELAPSE_MINI_FORCE_CREATION} == true AND we're at a 
    +				#		${TIMELAPSE_MINI_FREQUENCY} boundary (i.e., ${MOD} -eq 0)
    +
    +				if [[ ${LEFT} -le 0 ||
    +						( ${TIMELAPSE_MINI_FORCE_CREATION} == "true" && ${MOD} -eq 0 ) ]]; then
    +					CREATE="true"
     				fi
     			fi
    -			if [[ ${TIMELAPSE_MINI_FORCE_CREATION} == "true" || ${LEFT} -le 0 ]]; then
    +
    +			if [[ ${CREATE} == "true" ]]; then
     				# Create a mini-timelapse
     				# This ALLSKY_DEBUG_LEVEL should be same as what's in upload.sh
    +				# This causes timelapse.sh to print "before" and "after" debug messages.
     				if [[ ${ALLSKY_DEBUG_LEVEL} -ge 4 ]]; then
    -					# timelapse.sh produces a lot of debug output
    -					D="--debug --debug"
    -				elif [[ ${ALLSKY_DEBUG_LEVEL} -ge 2 ]]; then
    +					echo -e "NUM_IMAGES=${NUM_IMAGES}"
     					D="--debug"
     				else
    -					D=""
    +					D="--no-debug"
     				fi
     				O="${ALLSKY_TMP}/mini-timelapse.mp4"
    -				# shellcheck disable=SC2086
    -				"${ALLSKY_SCRIPTS}"/timelapse.sh ${D} --lock --output "${O}" \
    -					--mini --images "${MINI_TIMELAPSE_FILES}"
    -				RET=$?
    -				if [[ ${RET} -ne 0 ]]; then
    +
    +				"${ALLSKY_SCRIPTS}/timelapse.sh" --Last "$( basename "${FINAL_FILE}" )" \
    +					"${D}" --lock --output "${O}" --mini --images "${MINI_TIMELAPSE_FILES}"
    +				if [[ $? -ne 0 ]]; then
     					# failed so don't try to upload
     					TIMELAPSE_MINI_UPLOAD_VIDEO="false"
     				fi
    -				if [[ ${ALLSKY_DEBUG_LEVEL} -ge 2 ]]; then
    -					if [[ ${RET} -eq 0 ]]; then
    -						echo "${ME}: mini-timelapse created (last image: ${IMAGE_NAME})"
    -					else
    -						echo "${ME}: mini-timelapse creation returned with RET=${RET} (last image: ${IMAGE_NAME})"
    -					fi
    -				fi
     
    -				# Remove the oldest files, but not if we only created
    -				# this mini-timelapse because of a force.
    -				if [[ ${RET} -eq 0 && (${MOD} -ne 0 || ${TIMELAPSE_MINI_FORCE_CREATION} == "false") ]]; then
    +				# Remove the oldest files if we haven't reached the limit.
    +				if [[ ${LEFT} -le 0 ]]; then
     					KEEP=$((TIMELAPSE_MINI_IMAGES - TIMELAPSE_MINI_FREQUENCY))
    -					x="$(tail -${KEEP} "${MINI_TIMELAPSE_FILES}")"
    +					x="$( tail -${KEEP} "${MINI_TIMELAPSE_FILES}" )"
     					echo -e "${x}" > "${MINI_TIMELAPSE_FILES}"
     					if [[ ${ALLSKY_DEBUG_LEVEL} -ge 3 ]]; then
    -						echo -en "${YELLOW}${ME}: Replaced ${TIMELAPSE_MINI_FREQUENCY} oldest"
    -						echo -e " file(s) and added current image.${NC}" >&2
    +						echo -en "${YELLOW}${ME}: Replaced ${TIMELAPSE_MINI_FREQUENCY} oldest, LEFT=$LEFT, KEEP=$KEEP"
    +						echo -e " timelapse file(s).${NC}" >&2
     					fi
     				fi
    +
     			else
     				# Not ready to create yet
     				if [[ ${ALLSKY_DEBUG_LEVEL} -ge 3 ]]; then
    -					echo -n "${ME}: Not creating mini timelapse: "
    +					echo -n "NUM_IMAGES=${NUM_IMAGES}: Not creating mini-timelapse: "
     					if [[ ${MOD} -eq 0 ]]; then
    -						echo "${LEFT} images(s) left."
    +						echo "${LEFT} images(s) left."		# haven't reached limit
     					else
     						echo "$((TIMELAPSE_MINI_FREQUENCY - MOD)) images(s) left in frequency."
     					fi
    @@ -400,16 +392,16 @@ if [[ ${SAVE_IMAGE} == "true" ]]; then
     	fi
     fi
     
    -if [[ ${IMG_UPLOAD} == "true" || (${TIMELAPSE_MINI_UPLOAD_VIDEO} == "true" && ${SAVE_IMAGE} == "true") ]]; then
    -	#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -	source "${ALLSKY_CONFIG}/ftp-settings.sh"	|| exit ${ALLSKY_ERROR_STOP}
    +if [[ ${TIMELAPSE_MINI_UPLOAD_VIDEO} == "false" ]]; then
    +	# Don't deleate a lock file that belongs to another running process.
    +	ALLSKY_TIMELAPSE_PID_FILE=""
     fi
     
     # If upload is true, optionally create a smaller version of the image; either way, upload it
    -RET=0
    -if [[ ${IMG_UPLOAD} == "true" ]]; then
    +IMG_UPLOAD_FREQUENCY="$( settings ".imageuploadfrequency" )"
    +if [[ ${IMG_UPLOAD_FREQUENCY} -gt 0 ]]; then
     	# First check if we should upload this image
    -	if [[ ${IMG_UPLOAD_FREQUENCY} != "1" ]]; then
    +	if [[ ${IMG_UPLOAD_FREQUENCY} -ne 1 ]]; then
     		FREQUENCY_FILE="${ALLSKY_TMP}/IMG_UPLOAD_FREQUENCY.txt"
     		if [[ ! -f ${FREQUENCY_FILE} ]]; then
     			# The file may have been deleted, or the user may have just changed the frequency.
    @@ -422,6 +414,7 @@ if [[ ${IMG_UPLOAD} == "true" ]]; then
     			if [[ "${ALLSKY_DEBUG_LEVEL}" -ge 3 ]]; then
     				echo "${ME}: resetting LEFT counter to ${IMG_UPLOAD_FREQUENCY}, then uploading image."
     			fi
    +
     			echo "${IMG_UPLOAD_FREQUENCY}" > "${FREQUENCY_FILE}"
     		else
     			# Not ready to upload yet, so decrement the counter
    @@ -430,73 +423,97 @@ if [[ ${IMG_UPLOAD} == "true" ]]; then
     			# This ALLSKY_DEBUG_LEVEL should be same as what's in upload.sh
     			[[ ${ALLSKY_DEBUG_LEVEL} -ge 3 ]] && echo "${ME}: Not uploading image: ${LEFT} images(s) left."
     
    -			IMG_UPLOAD="false"
    +			IMG_UPLOAD_FREQUENCY=0
     		fi
     	fi
     fi
    -if [[ ${IMG_UPLOAD} == "true" ]]; then
    -	# We no longer use the "permanent" image name; instead, use the one the user specified
    -	# in the config file (${FULL_FILENAME}).
    -	if [[ ${RESIZE_UPLOADS} == "true" ]]; then
    -		# Need a copy of the image since we are going to resize it.
    -		# Put the copy in ${WORKING_DIR}.
    -		FILE_TO_UPLOAD="${WORKING_DIR}/resize-${IMAGE_NAME}"
    -		S="${RESIZE_UPLOADS_WIDTH}x${RESIZE_UPLOADS_HEIGHT}"
    -		[ "${ALLSKY_DEBUG_LEVEL}" -ge 4 ] && echo "${ME}: Resizing upload file '${FILE_TO_UPLOAD}' to ${S}"
    -		if ! convert "${CURRENT_IMAGE}" -resize "${S}" -gravity East -chop 2x0 "${FILE_TO_UPLOAD}" ; then
    -			echo -e "${YELLOW}*** ${ME}: WARNING: RESIZE_UPLOADS failed; continuing with larger image.${NC}"
    -			# We don't know the state of $FILE_TO_UPLOAD so use the larger file.
    +
    +# image.jpg and mini-timelapse overwrite the prior files so are not copied to a local Website.
    +# Instead, the local Website points to the files in ${SAVE_DIR}.
    +
    +RET=0
    +if [[ ${IMG_UPLOAD_FREQUENCY} -gt 0 ]]; then
    +	R_WEB="$( settings ".useremotewebsite" )"
    +	R_SERVER="$( settings ".useremoteserver" )"
    +
    +	if [[ ${R_WEB} == "true" || ${R_SERVER} == "true" ]]; then
    +		W="$( settings ".imageresizeuploadswidth" )"
    +		H="$( settings ".imageresizeuploadsheight" )"
    +		if [[ ${W} -gt 0 && ${H} -gt 0 ]]; then
    +			RESIZE_UPLOADS="true"
    +			# Need a copy of the image since we are going to resize it.
    +			# Put the copy in ${WORKING_DIR}.
    +			FILE_TO_UPLOAD="${WORKING_DIR}/resize-${IMAGE_NAME}"
    +			S="${W}x${H}"
    +			[[ "${ALLSKY_DEBUG_LEVEL}" -ge 3 ]] && echo "${ME}: Resizing upload file '${FILE_TO_UPLOAD}' to ${S}"
    +			if ! convert "${CURRENT_IMAGE}" -resize "${S}" -gravity East -chop 2x0 "${FILE_TO_UPLOAD}" ; then
    +				echo -e "${YELLOW}*** ${ME}: WARNING: Resize Uploads failed; continuing with larger image.${NC}"
    +				# We don't know the state of $FILE_TO_UPLOAD so use the larger file.
    +				FILE_TO_UPLOAD="${CURRENT_IMAGE}"
    +			fi
    +		else
    +			RESIZE_UPLOADS="false"
     			FILE_TO_UPLOAD="${CURRENT_IMAGE}"
     		fi
    -	else
    -		FILE_TO_UPLOAD="${CURRENT_IMAGE}"
    -	fi
     
    -	if [[ ${IMG_UPLOAD_ORIGINAL_NAME} == "true" ]]; then
    -		DESTINATION_NAME=""
    -	else
    -		DESTINATION_NAME="${FULL_FILENAME}"
    -	fi
    +		if [[ ${R_WEB} == "true" ]]; then
    +			if [[ $( settings ".remotewebsiteimageuploadoriginalname" ) == "true" ]]; then
    +				DESTINATION_NAME=""
    +			else
    +				DESTINATION_NAME="${FULL_FILENAME}"
    +			fi
    +			# Goes in root of Website so second arg is "".
    +			upload_all --remote-web "${FILE_TO_UPLOAD}" "" "${DESTINATION_NAME}" "SaveImage"
    +			((RET += $?))
    +		fi
     
    -	"${ALLSKY_SCRIPTS}/upload.sh" "${FILE_TO_UPLOAD}" "${IMAGE_DIR}" "${DESTINATION_NAME}" "SaveImage" "${WEB_IMAGE_DIR}"
    -	RET=$?
    +		if [[ ${R_SERVER} == "true" ]]; then
    +			if [[ $( settings ".remoteserverimageuploadoriginalname" ) == "true" ]]; then
    +				DESTINATION_NAME=""
    +			else
    +				DESTINATION_NAME="${FULL_FILENAME}"
    +			fi
    +			# Goes in root of Website so second arg is "".
    +			upload_all --remote-server "${FILE_TO_UPLOAD}" "" "${DESTINATION_NAME}" "SaveImage"
    +			((RET += $?))
    +		fi
     
    -	[[ ${RESIZE_UPLOADS} == "true" ]] && rm -f "${FILE_TO_UPLOAD}"	# was a temporary file
    +		[[ ${RESIZE_UPLOADS} == "true" ]] && rm -f "${FILE_TO_UPLOAD}"	# was a temporary file
    +	fi
     fi
     
    -# If needed, upload the mini timelapse.  If upload.sh failed above, it will likely fail below.
    +# If needed, upload the mini timelapse.  If the upload failed above, it will likely fail below.
     if [[ ${TIMELAPSE_MINI_UPLOAD_VIDEO} == "true" && ${SAVE_IMAGE} == "true" && ${RET} -eq 0 ]] ; then
     	MINI="mini-timelapse.mp4"
     	FILE_TO_UPLOAD="${ALLSKY_TMP}/${MINI}"
     
    -	"${ALLSKY_SCRIPTS}/upload.sh" "${FILE_TO_UPLOAD}" "${IMAGE_DIR}" "${MINI}" "MiniTimelapse" "${WEB_IMAGE_DIR}"
    +	upload_all --remote-web --remote-server "${FILE_TO_UPLOAD}" "" "${MINI}" "MiniTimelapse"
     	RET=$?
    -	if [[ ${RET} -eq 0 && ${TIMELAPSE_MINI_UPLOAD_THUMBNAIL} == "true" ]]; then
    +	if [[ ${RET} -eq 0 && $( settings ".minitimelapseuploadthumbnail" ) == "true" ]]; then
     		UPLOAD_THUMBNAIL_NAME="mini-timelapse.jpg"
     		UPLOAD_THUMBNAIL="${ALLSKY_TMP}/${UPLOAD_THUMBNAIL_NAME}"
     		# Create the thumbnail for the mini timelapse, then upload it.
     		rm -f "${UPLOAD_THUMBNAIL}"
     		make_thumbnail "00" "${FILE_TO_UPLOAD}" "${UPLOAD_THUMBNAIL}"
     		if [[ ! -f ${UPLOAD_THUMBNAIL} ]]; then
    -			echo "${ME}Mini timelapse thumbnail not created!"
    +			echo "${ME}: Mini timelapse thumbnail not created!"
     		else
     			# Use --silent because we just displayed message(s) above for this image.
    -			if [[ -n ${WEB_VIDEOS_DIR} ]]; then
    -				x="${WEB_VIDEOS_DIR}/thumbnails"
    -			else
    -				x=""
    -			fi
    -			"${ALLSKY_SCRIPTS}/upload.sh" --silent \
    +			upload_all --remote-web --remote-server --silent \
     				"${UPLOAD_THUMBNAIL}" \
    -				"${IMAGE_DIR}" \
    +				"" \
     				"${UPLOAD_THUMBNAIL_NAME}" \
    -				"MiniThumbnail" \
    -				"${x}"
    +				"MiniThumbnail"
     		fi
     	fi
     fi
     
    +# We're done so remove the lock file.
    +[[ -n ${ALLSKY_TIMELAPSE_PID_FILE} ]] && rm -f "${ALLSKY_TIMELAPSE_PID_FILE}"
    +
     # We create ${WEBSITE_FILE} as late as possible to avoid it being overwritten.
     mv "${SAVED_FILE}" "${WEBSITE_FILE}"
     
    +set_allsky_status "${ALLSKY_STATUS_RUNNING}"
    +
     exit 0
    diff --git a/scripts/testUpload.sh b/scripts/testUpload.sh
    new file mode 100755
    index 000000000..b3965994d
    --- /dev/null
    +++ b/scripts/testUpload.sh
    @@ -0,0 +1,305 @@
    +#!/bin/bash
    +
    +# Run upload.sh using a test file and if it doesn't work, parse the output
    +# looking for errors that are easy for users to miss.
    +
    +# Allow this script to be executed manually, which requires ALLSKY_HOME to be set.
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
    +ME="$( basename "${BASH_ARGV0}" )"
    +
    +#shellcheck source-path=.
    +source "${ALLSKY_HOME}/variables.sh"		|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${EXIT_ERROR_STOP}"
    +
    +usage_and_exit()
    +{
    +	local RET=${1}
    +	{
    +		[[ ${RET} -ne 0 ]] && echo -en "${RED}"
    +		[[ ${RET} -eq 2 ]] && echo -e "\nERROR: You must specify --website and/or --server\n"
    +
    +		echo    "Usage: ${ME} [--help] [--debug] [--silent] [--file f] --website  and/or  --server"
    +		echo -e "\nWhere:"
    +		echo -e "\t'--silent' only outputs errors."
    +		echo -e "\t'--file f' optionally specifies the test file to upload."
    +		[[ ${RET} -ne 0 ]] && echo -e "${NC}"
    +	} >&2
    +	exit "${RET}"
    +}
    +
    +OK="true"
    +DEBUG="false"
    +SILENT="false"
    +TEST_FILE="/tmp/${ME}.txt"
    +DO_WEBSITE="false"
    +DO_SERVER="false"
    +while [[ $# -gt 0 ]]; do
    +	ARG="${1}"
    +	case "${ARG,,}" in
    +		--help)
    +			HELP="true"
    +			;;
    +		--debug)
    +			DEBUG="true"
    +			;;
    +		--silent)
    +			SILENT="true"		# only output errors
    +			;;
    +		--file)
    +			TEST_FILE="${2}"
    +			shift
    +			;;
    +		--website)
    +			DO_WEBSITE="true"
    +			;;
    +		--server)
    +			DO_SERVER="true"
    +			;;
    +		-*)
    +			echo -e "${RED}Unknown argument '${ARG}'.${NC}" >&2
    +			OK="false"
    +			;;
    +		*)
    +			break	# done with arguments
    +			;;
    +	esac
    +	shift
    +done
    +[[ ${HELP} == "true" ]] && usage_and_exit 0
    +[[ ${OK} == "false" ]] && usage_and_exit 1
    +[[ ${DO_WEBSITE} == "false" && ${DO_SERVER} == "false" ]] && usage_and_exit 2
    +
    +
    +# Parse the output file and provide fixes when possible.
    +parse_output()
    +{	local FILE="${1}"
    +	local TYPE="${2}"
    +
    +	[[ ! -s ${FILE} ]] && return	# empty file - shouldn't happen...
    +
    +	local PROTOCOL  DIR  HOST  USER  STRING  S  SSL
    +
    +	if [[ ${TYPE} == "REMOTEWEBSITE" ]]; then
    +		PROTOCOL="remotewebsiteprotocol"
    +		DIR="remotewebsiteimagedir"
    +		S="Remote Website Settings"
    +	else
    +		PROTOCOL="remoteserverprotocol"
    +		DIR="remoteserverimagedir"
    +		S="Remote Server Settings"
    +	fi
    +	HOST="${TYPE}_HOST"
    +	USER="${TYPE}_USER"
    +
    +	# Parse output.
    +	STRING="host name resolve timeout"
    +	if grep --ignore-case --silent "${STRING}" "${FILE}" ; then
    +		HOST="$( settings ".${HOST}" "${ENV_FILE}" )"
    +		echo "* Host name '${HOST}' not found."
    +		echo "  FIX: Check the spelling of the server."
    +	   	echo "       Make sure your network is up."
    +	   	echo "       Make sure the network the server is on is up."
    +	fi >&2
    +
    +	STRING="User cannot log in|Login failed|Login incorrect"
    +	if grep -E --ignore-case --silent "${STRING}" "${FILE}" ; then
    +		echo "* Unable to login."
    +		echo "  FIX: Make sure the username and password are correct."
    +	fi >&2
    +
    +	STRING="max-retries exceeded"
    +	if grep --ignore-case --silent "${STRING}" "${FILE}" ; then
    +		echo "* Unable to login for unknown reason."
    +		echo "  FIX: Make sure the port is correct and your network is working."
    +		PROTOCOL="$( settings ".${PROTOCOL}" )"
    +		if [[ ${PROTOCOL} == "sftp" ]]; then
    +			HOST="$( settings ".${HOST}" "${ENV_FILE}" )"
    +			USER="$( settings ".${USER}" "${ENV_FILE}" )"
    +			echo "       On your Pi, run:  ssh ${USER}@${HOST}"
    +			echo "       When prompted to enter 'yes' or 'no', enter 'yes'."
    +			echo "       You may need to do this if the IP address of your Pi changed."
    +		fi
    +	fi >&2
    +
    +	STRING="The system cannot find the file specified"
    +	if grep --ignore-case --silent "${STRING}" "${FILE}" ; then
    +		STRING="is current directory"
    +		if grep --ignore-case --silent "${STRING}" "${FILE}" ; then
    +			echo "* Login succeeded but unknown location found."
    +		else
    +			# This should never happen.
    +			# If we can't login we wouldn't know if the location was there.
    +			echo "* Login failed and unknown location found."
    +		fi
    +		DIR="$( settings ".${DIR}" )"
    +		if [[ -n ${DIR} ]]; then
    +			echo "  The 'Image Directory' in the WebUI's '${S}' section is '${DIR}'."
    +			echo "  FIX: make sure that directory exists on the server."
    +		else
    +			echo "  The 'Image Directory' in the WebUI's '${S}' section is empty."
    +			# TODO: can this ever happen?
    +			echo "  FIX: unknown - not sure why this failed."
    +		fi
    +	fi >&2
    +
    +	# Certificate-related issues
    +	STRING="The authenticity of host"
    +	if grep --ignore-case --silent "${STRING}" "${FILE}" ; then
    +		HOST="$( settings ".${HOST}" "${ENV_FILE}" )"
    +		USER="$( settings ".${USER}" "${ENV_FILE}" )"
    +		PROTOCOL="$( settings ".${PROTOCOL}" )"
    +		echo "* The remote machine doesn't know about your Pi."
    +		if [[ ${PROTOCOL} == "sftp" ]]; then
    +			echo "  This happens the first time you use Protocol 'sftp' on a new Pi."
    +			echo "  FIX: On your Pi, run:  ssh ${USER}@${HOST}"
    +			echo "       When prompted to enter 'yes' or 'no', enter 'yes'."
    +			echo "       You may need to do this if the IP address of your Pi changed."
    +		else
    +			echo "  This error usually only happens when using Protocol 'sftp' on a new Pi."
    +			echo "  You are using Protocol '${PROTOCOL}', so no know fix exists."
    +		fi
    +	fi >&2
    +
    +	STRING="certificate common name doesn't match requested host name"
    +	if grep --ignore-case --silent "${STRING}" "${FILE}" ; then
    +		echo "* Certificate verification issue."
    +		echo "  FIX: Do one of the following on your Pi:"
    +		echo "    echo 'set ssl:check-hostname' > ~/.lftprc"
    +		echo "  or"
    +		echo "    In the WebUI's '${S}' section set 'FTP Commands' to 'set ssl:check-hostname'."
    +	fi >&2
    +
    +	STRING="Not trusted"
    +	if grep --ignore-case --silent "${STRING}" "${FILE}" ; then
    +		SSL="set ssl:verify-certificate no"
    +		echo "* Certificate verification issue."
    +		echo "  FIX: do one of the following on your Pi:"
    +		echo "    echo '${SSL}' > ~/.lftprc"
    +		echo "  or"
    +		echo "    In the WebUI's '${S}' section set 'FTP Commands' to '${SSL}'."
    +	fi >&2
    +
    +
    +	{
    +		echo -e "\n=================== RAW OUTPUT:"
    +		indent "${YELLOW}$( < "${FILE}" )${NC}\n"
    +	} >&2
    +}
    +
    +
    +# Test an upload.
    +do_test()
    +{
    +	local TYPE="${1}"
    +	local bTEST_FILE OUTPUT_FILE HUMAN_TYPE PROTOCOL DIR REMOTE CMD D
    +
    +	bTEST_FILE="$( basename "${TEST_FILE}" )"
    +	if [[ ! -f ${TEST_FILE} ]]; then
    +		echo "Test file for ${TYPE}" > "${TEST_FILE}" || return 1
    +	fi
    +
    +	OUTPUT_FILE="${ALLSKY_TMP}/${ME}-${TYPE}.txt"
    +
    +	if [[ ${TYPE} == "REMOTEWEBSITE" ]]; then
    +		HUMAN_TYPE="Remote Website"
    +		PROTOCOL="remotewebsiteprotocol"
    +		DIR="remotewebsiteimagedir"
    +		REMOTE="web"
    +	else
    +		HUMAN_TYPE="Remote Server"
    +		PROTOCOL="remoteserverprotocol"
    +		DIR="remoteserverimagedir"
    +		REMOTE="server"
    +	fi
    +
    +	PROTOCOL="$( settings ".${PROTOCOL}" )"
    +	if [[ $? -ne 0 || -z ${PROTOCOL} ]]; then
    +		echo -e "${RED}${ME}: could not find protocol for ${HUMAN_TYPE}; unable to test.${NC}" >&2
    +		return 1
    +	fi
    +
    +	DIR="$( settings ".${DIR}" )"
    +	DIR="${DIR:=null}"
    +
    +	CMD="${ALLSKY_SCRIPTS}/upload.sh --debug --remote-${REMOTE}"
    +	CMD+=" ${TEST_FILE} ${DIR} ${bTEST_FILE} ${ME}"
    +	[[ ${DEBUG} == "true" ]] && echo -e "Executing:\n\t${CMD}"
    +	${CMD} > "${OUTPUT_FILE}" 2>&1
    +	RET=$?
    +	if [[ ${RET} -eq 0 ]]; then
    +		[[ ${SILENT} == false ]] && echo -e "${GREEN}Test upload to ${HUMAN_TYPE} succeeded.${NC}"
    +		if [[ -z ${DIR} || ${DIR} == "null" ]]; then
    +			D=""
    +		else
    +			D="${DIR}/"
    +		fi
    +		if [[ ${SILENT} == false ]]; then
    +			echo -en "\t"
    +			echo     "Please remove '${D}${bTEST_FILE}' on your server." >> "${MSG_FILE}"
    +		fi
    +		if [[ -s ${OUTPUT_FILE} && ${DEBUG} == "true" ]]; then
    +			echo -e "OUTPUT:"
    +			echo -e "${YELLOW}$( < "${OUTPUT_FILE}" )${NC}\n"
    +		fi
    +	else
    +		echo -ne "${RED}"
    +		echo -n  "Test upload to ${HUMAN_TYPE} FAILED with RET=${RET}."
    +		echo -e  "${NC}"
    +		parse_output "${OUTPUT_FILE}" "${TYPE}"
    +	fi
    +
    +	rm -f "${TEST_FILE}"
    +	[[ ! -s ${OUTPUT_FILE} ]] && rm -f "${OUTPUT_FILE}"
    +
    +	return "${RET}"
    +}
    +
    +# ========================= main body of program
    +MSG_FILE="/tmp/$$"
    +ERR_MSG=""
    +OK_MSG=""
    +RET=0
    +if [[ ${DO_WEBSITE} == "true" ]]; then
    +	if X="$( do_test "REMOTEWEBSITE" 2>&1 )" ; then
    +		OK_MSG+="${X}"
    +	else
    +		ERR_MSG+="${X}"
    +		RET=1
    +	fi
    +fi
    +if [[ ${DO_SERVER} == "true" ]]; then
    +	if X="$( do_test "REMOTESERVER" 2>&1 )" ; then
    +		OK_MSG+="${X}"
    +	else
    +		ERR_MSG+="${X}"
    +		RET=1
    +	fi
    +fi
    +
    +if [[ -n ${ERR_MSG} ]]; then
    +	if [[ ${ON_TTY} == "true" ]]; then
    +		echo -e "\n${ERR_MSG}" >&2
    +	else
    +		"${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${ERR_MSG}"
    +	fi
    +fi
    +if [[ -n ${OK_MSG} ]]; then
    +	if [[ ${ON_TTY} == "true" ]]; then
    +		echo -e "\n${OK_MSG}" >&2
    +	else
    +		"${ALLSKY_SCRIPTS}/addMessage.sh" "success" "${OK_MSG}"
    +	fi
    +fi
    +
    +if [[ -s ${MSG_FILE} ]]; then
    +	M="$( < "${MSG_FILE}" )"
    +	if [[ ${ON_TTY} == "true" ]]; then
    +		echo -e "\n${M}"
    +	else
    +		"${ALLSKY_SCRIPTS}/addMessage.sh" "info" "${M}"
    +	fi
    +fi
    +rm -f "${MSG_FILE}"
    +
    +exit ${RET}
    diff --git a/scripts/timelapse.sh b/scripts/timelapse.sh
    index eec8fe3a6..7757a8770 100755
    --- a/scripts/timelapse.sh
    +++ b/scripts/timelapse.sh
    @@ -1,35 +1,42 @@
     #!/bin/bash
     
     # Allow this script to be executed manually, which requires ALLSKY_HOME to be set.
    -[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$(realpath "$(dirname "${BASH_ARGV0}")/..")"
    -ME="$(basename "${BASH_ARGV0}")"
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
    +ME="$( basename "${BASH_ARGV0}" )"
     
    -#shellcheck disable=SC2086 source-path=.
    -source "${ALLSKY_HOME}/variables.sh" || exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086 source-path=scripts
    -source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/config.sh" || exit ${ALLSKY_ERROR_STOP}
    +#shellcheck source-path=.
    +source "${ALLSKY_HOME}/variables.sh"		|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${EXIT_ERROR_STOP}"
     
     
     ENTERED="$*"
    -DEBUG=0
    +DEBUG="false"
     HELP="false"
     IS_MINI="false"
     LOCK="false"
     IMAGES_FILE=""
    +IMAGE_NAME="${FILENAME}"
     OUTPUT_FILE=""
     while [[ $# -gt 0 ]]; do
    -	case "${1}" in
    +	ARG="${1}"
    +	case "${ARG,,}" in
     			-h | --help)
     				HELP="true"
     				;;
     			-d | --debug)
    -				((DEBUG++))
    +				DEBUG="true"
    +				;;
    +			--no-debug)
    +				DEBUG="false"
     				;;
     			-l | --lock)
     				LOCK="true"
     				;;
    +			--filename)
    +				IMAGE_NAME="${2}"
    +				shift
    +				;;
     			-o | --output)
     				OUTPUT_FILE="${2}"
     				shift
    @@ -38,11 +45,14 @@ while [[ $# -gt 0 ]]; do
     				IMAGES_FILE="${2}"
     				shift
     				;;
    +			-L | --last)			# this is just so the last image name appears in "ps" output
    +				shift
    +				;;
     			-m | --mini)
     				IS_MINI="true"
     				;;
     			-*)
    -				echo -e "${RED}${ME}: Unknown argument '${1}' ignoring.${NC}" >&2
    +				echo -e "${RED}${ME}: Unknown argument '${ARG}' ignoring.${NC}" >&2
     				HELP="true"
     				;;
     			*)
    @@ -56,34 +66,39 @@ usage_and_exit()
     {
     	RET=$1
     	XD="/some_nonstandard_path"
    -	TODAY="$(date +%Y%m%d)"
    -	[[ ${RET} -ne 0 ]] && echo -en "${RED}"
    -	echo -n "Usage: ${ME} [--debug] [--help] [--lock] [--output file] [--mini] {--images file | <INPUT_DIR> }"
    -	echo -e "${NC}"
    -	echo "    example: ${ME} ${TODAY}"
    -	echo "    or:      ${ME} --output '${XD}' ${TODAY}"
    -	echo
    -	echo -en "${YELLOW}"
    -	echo
    -	echo "You entered: ${ME} ${ENTERED}"
    -	echo
    -	echo "The list of images is determined in one of two ways:"
    -	echo "1. Looking in '<INPUT_DIR>' for files with an extension of '${EXTENSION}'."
    -	echo "   If <INPUT_DIR> is NOT a full path name it is assumed to be in '${ALLSKY_IMAGES}',"
    -	echo "   which allows using images on a USB stick, for example."
    -	echo "   The timelapse file is stored in <INPUT_DIR> and is called 'allsky-<BASENAME_DIR>.mp4',"
    -	echo "   where <BASENAME_DIR> is the basename of <INPUT_DIR>."
    -	echo
    -	echo "2. Specifying '--images file' uses the images listed in 'file'; <INPUT_DIR> is not used."
    -	echo "   The timelapse file is stored in the same directory as the first image."
    -	echo
    -	echo "'--lock' ensures only one instance of ${ME} runs at a time."
    -	echo "'--output file' overrides the default storage location and file name."
    -	echo "'--mini' uses the MINI_TIMELAPSE settings and the timelapse file is"
    -	echo "   called 'mini-timelapse.mp4' if '--output' isn't used."
    -	echo -en "${NC}"
    -	# shellcheck disable=SC2086
    -	exit ${RET}
    +	TODAY="$( date +%Y%m%d )"
    +	{
    +		[[ ${RET} -ne 0 ]] && echo -en "${RED}"
    +		echo -n "Usage: ${ME} [--debug] [--help] [--lock] [--output file]"
    +		echo -en "\t[--mini] [--filename file] {--images file | <INPUT_DIR> }"
    +		echo -e "${NC}"
    +		echo "    example: ${ME} ${TODAY}"
    +		echo "    or:      ${ME} --output '${XD}' ${TODAY}"
    +		echo
    +		echo -en "${YELLOW}"
    +		echo
    +		echo "You entered: ${ME} ${ENTERED}"
    +		echo
    +		echo "The list of images is determined in one of two ways:"
    +		echo "1. Looking in '<INPUT_DIR>' for files with an extension of '${EXTENSION}'."
    +		echo "   If <INPUT_DIR> is a full path name all files ending in '${EXTENSION}' are used,"
    +		echo "   otherwise <INPUT_DIR> is assumed to be in '${ALLSKY_IMAGES}' and"
    +		echo "   only files begining with '${IMAGE_NAME}' are use."
    +		echo "   The timelapse is stored in <INPUT_DIR> and is called 'allsky-<BASENAME_DIR>.mp4',"
    +		echo "   where <BASENAME_DIR> is the basename of <INPUT_DIR>."
    +		echo
    +		echo "2. Specifying '--images file' uses the images listed in 'file'; <INPUT_DIR> is not used."
    +		echo "   The timelapse file is stored in the same directory as the first image."
    +		echo
    +		echo "'--lock' ensures only one instance of ${ME} runs at a time."
    +		echo "'--output file' overrides the default storage location and file name."
    +		echo "'--mini' uses the MINI_TIMELAPSE settings and the timelapse file is"
    +		echo "   called 'mini-timelapse.mp4' if '--output' isn't used."
    +		echo "'--filename file' uses 'file' as the begininning of the file names." 
    +		echo "'  This is useful if creating timelapse of non-allsky files."
    +		echo -en "${NC}"
    +	} >&2
    +	exit "${RET}"
     }
     if [[ -n ${IMAGES_FILE} ]]; then
     	# If IMAGES_FILE is specified there should be no other arguments.
    @@ -94,11 +109,14 @@ fi
     [[ ${HELP} == "true" ]] && usage_and_exit 0
     
     OUTPUT_DIR=""
    +LAST_IMAGE=""
    +
     if [[ -n ${IMAGES_FILE} ]]; then
     	if [[ ! -s ${IMAGES_FILE} ]]; then
     		echo -e "${RED}*** ${ME} ERROR: '${IMAGES_FILE}' does not exist or is empty!${NC}"
     		exit 3
     	fi
    +	LAST_IMAGE="$( tail -1 "${IMAGES_FILE}" )"
     	INPUT_DIR=""		# Not used
     else
     	INPUT_DIR="${1}"
    @@ -107,6 +125,9 @@ else
     	DIRNAME="$( dirname "${INPUT_DIR}" )"
     	if [[ ${DIRNAME} == "." ]]; then
     		INPUT_DIR="${ALLSKY_IMAGES}/${INPUT_DIR}"	# Need full pathname for links
    +	else
    +		# Full path name - use all images with ${EXTENSION}.
    +		IMAGE_NAME=""
     	fi
     	OUTPUT_DIR="${INPUT_DIR}"	# default location
     
    @@ -116,30 +137,60 @@ else
     	fi
     fi
     
    +if ! KEEP_SEQUENCE="$( settings ".timelapsekeepsequence" 2>&1 )" ; then
    +	# The settings file may not exist or may be corrupt.
    +	echo -e "${RED}*** ${ME} ERROR: Unable to get .timelapsekeepsequence:"
    +	echo "${KEEP_SEQUENCE}"
    +	echo -e "${NC}"
    +	exit 4
    +fi
    +
    +MY_PID="$$"
    +if [[ ${DEBUG} == "true" ]]; then
    +	# Output one string so it's all on one line in log file.
    +	MSG="${ME}: ${GREEN}Starting"
    +	[[ ${IS_MINI} == "true" ]] && MSG+=" mini "
    +	MSG+="timelapse"
    +	[[ -n ${LAST_IMAGE} ]] && MSG+=", last image = $( basename "${LAST_IMAGE}" )"
    +	echo -e "${MSG}, my PID=${MY_PID}.${NC}"
    +fi
    +
     if [[ ${LOCK} == "true" ]]; then
    -	PID_FILE="${ALLSKY_TMP}/timelapse-pid.txt"
    -	ABORTED_MSG1="Another timelapse creation is in progress so this one was aborted."
    -	ABORTED_FIELDS="$( basename "${OUTPUT_FILE}" )"
    +	if [[ ${DEBUG} == "true" ]]; then
    +		if [[ -s ${ALLSKY_TIMELAPSE_PID_FILE} ]]; then
    +			echo "  > ALLSKY_TIMELAPSE_PID_FILE contains $( < "${ALLSKY_TIMELAPSE_PID_FILE}" )"
    +		else
    +			echo "  > No ALLSKY_TIMELAPSE_PID_FILE"
    +		fi
    +	fi
    +	ABORTED_MSG1="Another timelapse creation is in progress so this one (${PPID}) was aborted."
    +	ABORTED_FIELDS="$( basename "${OUTPUT_FILE}" )\tMY_PID=${MY_PID}\tPPID=${PPID}"
     	ABORTED_MSG2="timelapse creations"
     	if [[ ${IS_MINI} == "true" ]]; then
     		CAUSED_BY="This could be caused by unreasonable TIMELAPSE_MINI_IMAGES and TIMELAPSE_MINI_FREQUENCY settings."
     	else
    -		CAUSED_BY="Unknown cause - see /var/log/allsky.log."
    +		CAUSED_BY="Unknown cause - see ${ALLSKY_LOG}."
     	fi
    -	if ! one_instance --pid-file "${PID_FILE}" \
    +	# We need to use the PID of our parent, not our PID, since our parent
    +	# may also upload the timelapse file, and 
    +	if ! one_instance --pid-file "${ALLSKY_TIMELAPSE_PID_FILE}" --pid "${PPID}" \
     			--aborted-count-file "${ALLSKY_ABORTEDTIMELAPSE}" \
     			--aborted-fields "${ABORTED_FIELDS}" \
     			--aborted-msg1 "${ABORTED_MSG1}" --aborted-msg2 "${ABORTED_MSG2}" \
     			--caused-by "${CAUSED_BY}" ; then
     		exit 5
     	fi
    +	if [[ ${DEBUG} == "true" ]]; then
    +		echo "  > Got lock, new PID=$( < "${ALLSKY_TIMELAPSE_PID_FILE}" )"
    +	fi
     	SEQUENCE_DIR="${ALLSKY_TMP}/sequence-lock-timelapse"
     else
     	SEQUENCE_DIR="${ALLSKY_TMP}/sequence-timelapse"
     	# Use (hopefully) unique names for the sequence directories in case there are
     	# multiple simultaneous timelapse being created.
    -	[[ -n ${INPUT_DIR} ]] && SEQUENCE_DIR="${SEQUENCE_DIR}.$( basename "${INPUT_DIR}" )"
    -	PID_FILE=""
    +	[[ -n ${INPUT_DIR} ]] && SEQUENCE_DIR+=".$( basename "${INPUT_DIR}" )"
    +
    +	ALLSKY_TIMELAPSE_PID_FILE=""
     fi
     
     if [[ -z ${OUTPUT_FILE} ]]; then
    @@ -170,7 +221,7 @@ fi
     TMP="${ALLSKY_TMP}/timelapseTMP.txt"
     [[ ${IS_MINI} == "false"  ]] && : > "${TMP}"		# Only create when NOT doing mini-timelapses
     
    -if [[ ${KEEP_SEQUENCE} == "false" ]]; then
    +if [[ ${KEEP_SEQUENCE} == "false" || ! -d ${SEQUENCE_DIR} ]]; then
     	rm -fr "${SEQUENCE_DIR}"
     	mkdir -p "${SEQUENCE_DIR}"
     
    @@ -185,7 +236,7 @@ if [[ ${KEEP_SEQUENCE} == "false" ]]; then
     		# have thousands of images.
     		echo "[end]"		# signals end of the list
     	else
    -		ls -rt "${INPUT_DIR}/${FILENAME}-"*".${EXTENSION}" 2>/dev/null
    +		ls -rt "${INPUT_DIR}/${IMAGE_NAME}"*".${EXTENSION}" 2>/dev/null
     		echo "[end]"
     	fi | while read -r IMAGE
     		do
    @@ -200,8 +251,8 @@ if [[ ${KEEP_SEQUENCE} == "false" ]]; then
     				# This user or something else may have removed it.
     				if [[ ! -s ${IMAGE} ]]; then
     					if [[ ! -f ${IMAGE} ]]; then
    -# TODO: would be nice to remove from the file,
    -# but we don't create/update the file so any change we make may be overwritten.
    +						# It would be nice to remove from the file, but we don't
    +						# create/update the file so any change we make may be overwritten.
     						MSG="not found"
     					else
     						MSG="has nothing in it"
    @@ -215,69 +266,75 @@ if [[ ${KEEP_SEQUENCE} == "false" ]]; then
     				NUM="$( printf "%04d" "${NUM_IMAGES}" )"
     				ln -s "${IMAGE}" "${SEQUENCE_DIR}/${NUM}.${EXTENSION}"
     			fi
    -		done
    +	done
     	if [[ $? -ne 0 ]]; then
     		echo -e "${RED}*** ${ME} ERROR: No images found in '${INPUT_DIR}'!${NC}"
     		rm -fr "${SEQUENCE_DIR}"
    -		[[ -n ${PID_FILE} ]] && rm -f "${PID_FILE}"
    -		exit 1
    +		[[ -n ${ALLSKY_TIMELAPSE_PID_FILE} ]] && rm -f "${ALLSKY_TIMELAPSE_PID_FILE}"
    +		exit 98		# this number should match what's in {startrails|keogram}.cpp
     	fi
     else
    -	echo -e "${ME} ${YELLOW}"
    -	echo "Not regenerating sequence because KEEP_SEQUENCE is enabled."
    +	echo -en "${ME} ${YELLOW}"
    +	echo -n "Not regenerating sequence because 'Keep Timelapse Sequence' is enabled."
     	echo -e "${NC}"
     fi
     
    -SCALE=""
    -
     # "-loglevel warning" gets rid of the dozens of lines of garbage output
     # but doesn't get rid of "deprecated pixel format" message when -pix_ftm is "yuv420p".
    -# set FFLOG=info in config.sh if you want to see what's going on for debugging.
    +# Bitrate settings are integers so do NOT include the "k", so add below.
     if [[ ${IS_MINI} == "true" ]]; then
    -	FPS="${TIMELAPSE_MINI_FPS}"
    -	TIMELAPSE_BITRATE="${TIMELAPSE_MINI_BITRATE}"
    -	if [[ ${TIMELAPSE_MINI_WIDTH} != "0" ]]; then
    -		SCALE="-filter:v scale=${TIMELAPSE_MINI_WIDTH}:${TIMELAPSE_MINI_HEIGHT}"
    -	fi
    -elif [[ ${TIMELAPSEWIDTH} != "0" ]]; then
    -	SCALE="-filter:v scale=${TIMELAPSEWIDTH}:${TIMELAPSEHEIGHT}"
    +	FPS="$( settings ".minitimelapsefps" )"
    +	TIMELAPSE_BITRATE="$( settings ".minitimelapsebitrate" )"
    +	W="$( settings ".minitimelapsewidth" )"
    +	H="$( settings ".minitimelapseheight" )"
    +else
    +	FPS="$( settings ".timelapsefps" )"
    +	TIMELAPSE_BITRATE="$( settings ".timelapsebitrate" )"
    +	W="$( settings ".timelapsewidth" )"
    +	H="$( settings ".timelapseheight" )"
    +fi
    +if [[ ${W} -gt 0 ]]; then
    +	SCALE="-filter:v scale=${W}:${H}"
    +else
    +	SCALE=""
     fi
    -# shellcheck disable=SC2086
    -X="$(ffmpeg -y -f image2 \
    +FFLOG="$( settings ".timelapsefflog" )"
    +VCODEC="$( settings ".timelapsevcodec" )"
    +PIX_FMT="$( settings ".timelapsepixfmt" )"
    +EXTRA="$( settings ".timelapseextraparameters" )"
    +# shellcheck disable=SC2086,SC2046
    +X="$( ffmpeg -y -f image2 \
     	-loglevel "${FFLOG}" \
     	-r "${FPS}" \
     	-i "${SEQUENCE_DIR}/%04d.${EXTENSION}" \
     	-vcodec "${VCODEC}" \
    -	-b:v "${TIMELAPSE_BITRATE}" \
    +	-b:v "${TIMELAPSE_BITRATE}k" \
     	-pix_fmt "${PIX_FMT}" \
     	-movflags +faststart \
    -	$SCALE \
    -	${TIMELAPSE_EXTRA_PARAMETERS} \
    -	"${OUTPUT_FILE}" 2>&1)"
    +	${SCALE} \
    +	${EXTRA} \
    +	"${OUTPUT_FILE}" 2>&1 )"
     RET=$?
     
     # The "deprecated..." message is useless and only confuses users, so hide it.
    -X="$(echo "${X}" | grep -v "deprecated pixel format used")"
    -[ "${X}" != "" ] && echo "${X}" >> "${TMP}"		# a warning/error message
    +X="$( echo "${X}" | grep -E -v "deprecated pixel format used|Processed " )"
    +[[ -n ${X} ]] && echo "${X}" >> "${TMP}"		# a warning/error message
     
     if [[ ${RET} -ne -0 ]]; then
    -	echo -e "\n${RED}*** $ME: ERROR: ffmpeg failed."
    -	echo -e "Error log:\n $( < "${TMP}" )'."
    -	echo "=============================================="
    -	echo "Links in '${SEQUENCE_DIR}' left for debugging."
    -	echo -e "Remove them when the problem is fixed.${NC}\n"
    -	rm -f "${OUTPUT_FILE}"	# don't leave around to confuse user
    +	echo -e "\n${RED}*** ${ME}: ERROR: ffmpeg failed."
     
    -	if [[ ${IS_MINI} == "true" ]]; then
    -		M="Mini-t"
    -	else
    -		M="T"
    +	# Check for common, known errors.
    +	if X="$( echo "${TMP}" | grep -E -i "Killed ffmpeg|malloc of size" )" ; then
    +		indent --spaces "${X}"
    +		echo -e "See the 'Troubleshooting -> Timelapse' documentation page for a fix.\n"
     	fi
    -	MSG="${M}imelapse creation for $( basename "$OUTPUT_FILE" ) failed!"
    -	"${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${MSG}"
    -
    -	[[ -n ${PID_FILE} ]] && rm -f "${PID_FILE}"
     
    +	indent --spaces "Output: $( < "${TMP}" )"
    +	echo
    +	echo "Links in '${SEQUENCE_DIR}' left for debugging."
    +	echo -e "Remove them when the problem is fixed.${NC}\n"
    +	rm -f "${OUTPUT_FILE}"	# don't leave around to confuse user
    +	[[ -n ${ALLSKY_TIMELAPSE_PID_FILE} ]] && rm -f "${ALLSKY_TIMELAPSE_PID_FILE}"
     	exit 1
     fi
     
    @@ -292,9 +349,15 @@ fi
     
     # timelapse is uploaded via generateForDay.sh (usually via endOfNight.sh), which called us.
     
    -[[ ${DEBUG} -ge 2 ]] && echo -e "${ME}: ${GREEN}Timelapse in ${OUTPUT_FILE}${NC}"
    +if [[ ${DEBUG} == "true" ]]; then
    +	# Output one string so it's all on one line in log file.
    +	MSG="${ME}: ${GREEN}"
    +	[[ ${IS_MINI} == "true" ]] && MSG+="mini "
    +	MSG+="timelapse creation finished"
    +	[[ -n ${LAST_IMAGE} ]] && MSG+=", last image = $( basename "${LAST_IMAGE}" )"
    +	echo -e "${MSG}, my PID=${MY_PID}.${NC}"
    +fi
     
    -[[ -n ${PID_FILE} ]] && rm -f "${PID_FILE}"
    +# Let our parent remove ${ALLSKY_TIMELAPSE_PID_FILE} when done.
     
     exit 0
    -
    diff --git a/scripts/updateWebsiteConfig.sh b/scripts/updateWebsiteConfig.sh
    index ffcafc814..c7e893309 100755
    --- a/scripts/updateWebsiteConfig.sh
    +++ b/scripts/updateWebsiteConfig.sh
    @@ -4,15 +4,13 @@
     # If no file is specified, use the local one if it exists, else use the remote one.
     
     # Allow this script to be executed manually, which requires several variables to be set.
    -[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$(realpath "$(dirname "${BASH_ARGV0}")/..")"
    -ME="$(basename "${BASH_ARGV0}")"
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
    +ME="$( basename "${BASH_ARGV0}" )"
     
    -#shellcheck disable=SC2086 source-path=.
    -source "${ALLSKY_HOME}/variables.sh"			|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086 source-path=scripts
    -source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/config.sh"				|| exit ${ALLSKY_ERROR_STOP}
    +#shellcheck disable=SC1091 source-path=.
    +source "${ALLSKY_HOME}/variables.sh"		|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${EXIT_ERROR_STOP}"
     
     function usage_and_exit()
     {
    @@ -22,11 +20,16 @@ function usage_and_exit()
     	else
     		C="${wERROR}"
     	fi
    -	echo -e "${C}Usage: ${ME} [--help] [--debug] [--verbosity silent|summary|verbose] [--local | --remote | --config file] key label new_value [...]${wNC}" >&2
    -	echo "There must be a multiple of 3 arguments." >&2
    -	# shellcheck disable=SC2086
    -	exit ${RET}
    +	{
    +		echo -en "${C}"
    +		echo -n "Usage: ${ME} [--help] [--debug] [--verbosity silent|summary|verbose]"
    +		echo -n " [--local | --remote | --config file] key label new_value [...]"
    +		echo -e "${wNC}"
    +		echo "There must be a multiple of 3 arguments."
    +	} >&2
    +	exit "${RET}"
     }
    +
     # Check arguments
     OK="true"
     HELP="false"
    @@ -36,7 +39,7 @@ CONFIG_FILE=""
     WEBSITE_TYPE="local and remote"
     while [[ $# -gt 0 ]]; do
     	ARG="${1}"
    -	case "${ARG}" in
    +	case "${ARG,,}" in
     		--help)
     			HELP="true"
     			;;
    @@ -110,6 +113,7 @@ while [[ $# -gt 0 ]]; do
     	FIELD="${1}"
     	LABEL="${2}"
     	NEW_VALUE="${3}"
    +
     	# Convert HTML code for apostrophy back to character.
     	apos="&#x27"
     	NEW_VALUE="${NEW_VALUE/${apos}/\'}"
    @@ -120,16 +124,22 @@ while [[ $# -gt 0 ]]; do
     
     	# Only put quotes around ${NEW_VALUE} if it's a string,
     	# i.e., not a number or a special name.
    -	if  [[ ! (${NEW_VALUE} =~ ${NUMRE}) && ${NEW_VALUE} != "true" && ${NEW_VALUE} != "false" && ${NEW_VALUE} != "null" ]]; then
    +	if  [[ ! (${NEW_VALUE} =~ ${NUMRE}) && ${NEW_VALUE} != "true" && ${NEW_VALUE} != "false" &&
    +			${NEW_VALUE} != "null" && ${NEW_VALUE} != "--delete" ]]; then
     		Q='"'
     		NEW_VALUE="${Q}${NEW_VALUE}${Q}"
     	fi
    -	JQ_STRING+=( "| .${FIELD} = ${NEW_VALUE}" )
    +	if [[ ${NEW_VALUE} == "--delete" ]]; then
    +		JQ_STRING+=( "| del(${FIELD})" )
    +		OUTPUT_MESSAGE+="'${LABEL}' deleted."
    +	else
    +		JQ_STRING+=( "| .${FIELD} = ${NEW_VALUE}" )
    +		OUTPUT_MESSAGE+="'${LABEL}' updated to ${wBOLD}${NEW}${wNBOLD}."
    +	fi
     
     	shift 3
     
    -	OUTPUT_MESSAGE="${OUTPUT_MESSAGE}'${LABEL}' updated to ${wBOLD}${NEW}${wNBOLD}."
    -	[ $# -gt 0 ] && OUTPUT_MESSAGE="${OUTPUT_MESSAGE}${wBR}"
    +	[ $# -gt 0 ] && OUTPUT_MESSAGE+="${wBR}"
     done
     
     
    @@ -142,11 +152,16 @@ if [[ ${DEBUG} == "true" ]]; then
     	echo -e "${wNC}"
     fi
     
    -if OUTPUT="$(jq "${S}" "${CONFIG_FILE}" 2>&1 > /tmp/x && mv /tmp/x "${CONFIG_FILE}")"; then
    +# Need to use "jq", not "settings".
    +if OUTPUT="$( jq "${S}" "${CONFIG_FILE}" 2>&1 > /tmp/x && mv /tmp/x "${CONFIG_FILE}" )"; then
     	if [[ ${VERBOSITY} == "verbose" ]]; then
     		echo -e "${wOK}${OUTPUT_MESSAGE}${wNC}"
     	elif [[ ${VERBOSITY} == "summary" ]]; then
    -		echo -e "${wOK}${LorR}Allsky Website ${ALLSKY_WEBSITE_CONFIGURATION_NAME} UPDATED${wNC}"
    +		if [[ -n ${CONFIG_FILE} ]]; then
    +			echo -e "${wOK}'${CONFIG_FILE}' UPDATED${wNC}"
    +		else
    +			echo -e "${wOK}${LorR}Allsky Website ${ALLSKY_WEBSITE_CONFIGURATION_NAME} UPDATED${wNC}"
    +		fi
     	fi		# nothing if "silent"
     	exit 0
     else
    diff --git a/scripts/upload.sh b/scripts/upload.sh
    index 7203c7ba5..22ecf08eb 100755
    --- a/scripts/upload.sh
    +++ b/scripts/upload.sh
    @@ -4,25 +4,22 @@
     # This is a separate script so it can also be used manually to test uploads.
     
     # Allow this script to be executed manually, which requires ALLSKY_HOME to be set.
    -[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}")/.." )"
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
     ME="$( basename "${BASH_ARGV0}" )"
     
     #shellcheck source-path=.
    -source "${ALLSKY_HOME}/variables.sh"		|| exit "${ALLSKY_ERROR_STOP}"
    +source "${ALLSKY_HOME}/variables.sh"		|| exit "${EXIT_ERROR_STOP}"
     #shellcheck source-path=scripts
    -source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${ALLSKY_ERROR_STOP}"
    -#shellcheck disable=SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/config.sh"			|| exit "${ALLSKY_ERROR_STOP}"
    -#shellcheck disable=SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/ftp-settings.sh"	|| exit "${ALLSKY_ERROR_STOP}"
    +source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${EXIT_ERROR_STOP}"
     
     
     usage_and_exit() {
     	RET=$1
     	[[ ${RET} -ne 0 ]] && echo -en "${RED}"
     	echo "*** Usage: ${ME} [--help] [--wait] [--silent] [--debug] \\"
    +	echo "               { --local-web | --remote-web | --remote-server } \\"
     	echo "               file_to_upload  directory  destination_file_name \\"
    -	echo "               [file_type] [local_directory]"
    +	echo "               [file_type]"
     	[[ ${RET} -ne 0 ]] && echo -e "${NC}"
     
     	echo
    @@ -30,14 +27,15 @@ usage_and_exit() {
     	echo "   '--help'    displays this message and exits."
     	echo "   '--wait'    waits for any upload of the same type to finish."
     	echo "   '--silent'  doesn't display any status messages."
    +	echo "   '--local-web'      copy to local Website."
    +	echo "   '--remote-web'     upload to the remote Website"
    +	echo "   '--remote-server'  upload to the remote server."
     	echo "   'file_to_upload' is the path name of the file to upload."
     	echo "   'directory' is the directory ON THE SERVER the file should be uploaded to."
     	echo "   'destination_file_name' is the name the file should be called ON THE SERVER."
     	echo "   'file_type' is an optional, temporary name to use when uploading the file."
    -	echo "   'local_directory' is the name of an optional local directory the file should be"
    -	echo "        copied to IN ADDITION TO being uploaded to a remote server."
     	echo
    -	echo "For example: ${ME}  keogram-20230710.jpg  /keograms  keogram.jpg"
    +	echo "For example: ${ME}  keogram-20240710.jpg  /keograms  keogram.jpg"
     
     	exit "${RET}"
     }
    @@ -47,37 +45,52 @@ HELP="false"
     WAIT="false"
     SILENT="false"
     DEBUG="false"
    +LOCAL="false"
    +REMOTE_WEB="false"
    +REMOTE_SERVER="false"
    +NUM=0
     RET=0
     while [[ $# -gt 0 ]]; do
    -	case "${1}" in
    +	ARG="${1}"
    +	case "${ARG,,}" in
     		--help)
     			HELP="true"
    -			shift
     			;;
     		--wait)
     			WAIT="true"
    -			shift
     			;;
     		--silent)
     			SILENT="true"
    -			shift
     			;;
     		--debug)
     			DEBUG="true"
    -			shift
    +			;;
    +		--local-web)
    +			LOCAL="true"
    +			(( NUM++ ))
    +			;;
    +		--remote-web)
    +			REMOTE_WEB="true"
    +			(( NUM++ ))
    +			;;
    +		--remote-server)
    +			REMOTE_SERVER="true"
    +			(( NUM++ ))
     			;;
     		-*)
    -			echo -e "${RED}Unknown argument '${1}'.${NC}" >&2
    -			shift
    +			echo -e "${RED}Unknown argument '${ARG}'.${NC}" >&2
     			RET=1
     			;;
     		*)
     			break		# done with arguments
     			;;
     	esac
    +	shift
     done
     [[ $# -lt 3 || ${RET} -ne 0 ]] && usage_and_exit 1
     [[ ${HELP} == "true" ]] && usage_and_exit 0
    +# Have to specify exactly one place to upload to.
    +[[ ${NUM} -ne 1 ]] && usage_and_exit 1
     
     FILE_TO_UPLOAD="${1}"
     if [[ ! -f ${FILE_TO_UPLOAD} ]]; then
    @@ -88,8 +101,10 @@ if [[ ! -f ${FILE_TO_UPLOAD} ]]; then
     fi
     
     DIRECTORY="${2}"
    +# Allow explicit empty directory.
    +[[ ${DIRECTORY} == "null" ]] && DIRECTORY=""
     DESTINATION_NAME="${3}"
    -[[ -z ${DESTINATION_NAME} ]] && DESTINATION_NAME="$(basename "${FILE_TO_UPLOAD}")"
    +[[ -z ${DESTINATION_NAME} ]] && DESTINATION_NAME="$( basename "${FILE_TO_UPLOAD}" )"
     # When run manually, the FILE_TYPE normally won't be given.
     FILE_TYPE="${4:-x}"		# A unique identifier for this type of file
     COPY_TO="${5}"
    @@ -100,13 +115,42 @@ if [[ -n ${COPY_TO} && ! -d ${COPY_TO} ]]; then
     	exit 2
     fi
     
    +function check_for_error_messages()
    +{
    +	local ERROR_MESSAGES="${1}"
    +	# Output any error messages
    +	if [[ -n ${ERROR_MESSAGES} ]]; then
    +		echo -e "Upload output from '${FILE_TO_UPLOAD}:\n   ${ERROR_MESSAGES}\n" >&2
    +		echo -e "${ERROR_MESSAGES}" > "${LOG}"
    +	fi
    +}
    +
     # To save a write to the SD card, only save output to ${LOG} on error.
     LOG="${ALLSKY_TMP}/upload_errors.txt"
     
    +if [[ ${LOCAL} == "true" ]]; then
    +	if [[ -z ${DIRECTORY} ]]; then
    +		DIRECTORY="${ALLSKY_WEBSITE}"
    +	elif [[ ${DIRECTORY:0:1} != "/" ]]; then
    +		DIRECTORY="${ALLSKY_WEBSITE}/${DIRECTORY}"
    +	fi
    +	# No need to set the lock for local copies - they are fast.
    +	if [[ ${SILENT} == "false" && ${ALLSKY_DEBUG_LEVEL} -ge 3 ]]; then
    +		echo "${ME}: Copying ${FILE_TO_UPLOAD} to ${DIRECTORY}/${DESTINATION_NAME}"
    +	fi
    +	OUTPUT="$( cp "${FILE_TO_UPLOAD}" "${DIRECTORY}/${DESTINATION_NAME}" 2>&1 )"
    +	RET=$?
    +	check_for_error_messages "${OUTPUT}"
    +	exit "${RET}"
    +fi
    +
    +
    +############## Remote Website or server
    +
     # Make sure only one upload of this file type happens at once.
     # Multiple concurrent uploads (which can happen if the system and/or network is slow can
     # cause errors and files left on the server.
    -PID_FILE="${ALLSKY_TMP}/${FILE_TYPE}-pid.txt"
    +
     if [[ ${WAIT} == "true" ]]; then
     	MAX_CHECKS=10
     	SLEEP="5s"
    @@ -114,8 +158,9 @@ else
     	MAX_CHECKS=2
     	SLEEP="10s"
     fi
    +PID_FILE="${ALLSKY_TMP}/${FILE_TYPE}-pid.txt"
     ABORTED_MSG1="Another '${FILE_TYPE}' upload is in progress so the new upload of"
    -ABORTED_MSG1="${ABORTED_MSG1} $(basename "${FILE_TO_UPLOAD}") was aborted."
    +ABORTED_MSG1+=" $( basename "${FILE_TO_UPLOAD}" ) was aborted."
     ABORTED_FIELDS="${FILE_TYPE}\t${FILE_TO_UPLOAD}"
     ABORTED_MSG2="uploads"
     CAUSED_BY="This could be caused network issues or by delays between images that are too short."
    @@ -126,17 +171,27 @@ if ! one_instance --sleep "${SLEEP}" --max-checks "${MAX_CHECKS}" --pid-file "${
     	exit 1
     fi
     
    -# Convert to lowercase so we don't care if user specified upper or lowercase.
    -PROTOCOL="${PROTOCOL,,}"
    +if [[ ${REMOTE_WEB} == "true" ]]; then
    +	prefix="remotewebsite"
    +	PREFIX="REMOTEWEBSITE"
    +else	# "server"
    +	prefix="remoteserver"
    +	PREFIX="REMOTESERVER"
    +fi
    +PROTOCOL="$( settings ".${prefix}protocol" )"
     
     # SIGTERM is sent by systemctl to stop Allsky.
    -# SIGHUP is sent to have the capture program reload their arguments.
    -# Ignore them so we don't leave a temporary or partially uploaded file if the service is stopped
    -# in the middle of an upload.
    +# SIGHUP is sent to have the capture program reload its arguments.
    +# Ignore them so we don't leave a temporary or partially uploaded file if the service
    +# is stopped in the middle of an upload.
     trap "" SIGTERM
     trap "" SIGHUP
     
     if [[ ${PROTOCOL} == "s3" ]] ; then
    +	AWS_CLI_DIR="$( settings ".${PREFIX}_AWS_CLI_DIR" "${ALLSKY_ENV}" )"
    +	S3_BUCKET="$( settings ".${PREFIX}_S3_BUCKET" "${ALLSKY_ENV}" )"
    +	S3_ACL="$( settings ".${PREFIX}_S3_ACL" "${ALLSKY_ENV}" )"
    +
     	DEST="s3://${S3_BUCKET}${DIRECTORY}/${DESTINATION_NAME}"
     	if [[ ${SILENT} == "false" && ${ALLSKY_DEBUG_LEVEL} -ge 3 ]]; then
     		echo "${ME}: Uploading ${FILE_TO_UPLOAD} to ${DEST}"
    @@ -145,31 +200,33 @@ if [[ ${PROTOCOL} == "s3" ]] ; then
     	RET=$?
     
     
    -elif [[ ${PROTOCOL} == "local" ]] ; then
    -	DEST="${DIRECTORY}/${DESTINATION_NAME}"
    -	if [[ ${SILENT} == "false" && ${ALLSKY_DEBUG_LEVEL} -ge 3 ]]; then
    -		echo "${ME}: Copying ${FILE_TO_UPLOAD} to ${DEST}"
    -	fi
    -	OUTPUT="$( cp "${FILE_TO_UPLOAD}" "${DEST}" 2>&1 )"
    -	RET=$?
    -
    +elif [[ "${PROTOCOL}" == "scp" || "${PROTOCOL}" == "rsync" ]] ; then
    +	REMOTE_USER="$( settings ".${PREFIX}_USER" "${ALLSKY_ENV}" )"
    +	REMOTE_HOST="$( settings ".${PREFIX}_HOST" "${ALLSKY_ENV}" )"
    +	REMOTE_PORT="$( settings ".${PREFIX}_PORT" "${ALLSKY_ENV}" )"
    +	SSH_KEY_FILE="$( settings ".${PREFIX}_SSH_KEY_FILE" "${ALLSKY_ENV}" )"
     
    -elif [[ "${PROTOCOL}" == "scp" ]] ; then
    -	#shellcheck disable=SC2153
     	DEST="${REMOTE_USER}@${REMOTE_HOST}:${DIRECTORY}/${DESTINATION_NAME}"
     	if [[ ${SILENT} == "false" && ${ALLSKY_DEBUG_LEVEL} -ge 3 ]]; then
     		echo "${ME}: Copying ${FILE_TO_UPLOAD} to ${DEST}"
     	fi
    -	[[ -n ${REMOTE_PORT} ]] && REMOTE_PORT="-P ${REMOTE_PORT}"
    -	# shellcheck disable=SC2086
    -	OUTPUT="$( scp -i "${SSH_KEY_FILE}" ${REMOTE_PORT} "${FILE_TO_UPLOAD}" "${DEST}" 2>&1 )"
    +	if [[ "${PROTOCOL}" == "scp" ]]; then
    +		[[ -n ${REMOTE_PORT} ]] && REMOTE_PORT="-P ${REMOTE_PORT}"
    +		# shellcheck disable=SC2086
    +		OUTPUT="$( scp -i "${SSH_KEY_FILE}" ${REMOTE_PORT} "${FILE_TO_UPLOAD}" "${DEST}" 2>&1 )"
    + 	else
    +		[[ -n ${REMOTE_PORT} ]] && REMOTE_PORT="-p ${REMOTE_PORT}"
    +		# shellcheck disable=SC2086
    +		OUTPUT="$( rsync -e "ssh -i ${SSH_KEY_FILE} ${REMOTE_PORT}" "${FILE_TO_UPLOAD}" "${DEST}" 2>&1 )"
    +  	fi
     	RET=$?
     
     
     elif [[ ${PROTOCOL} == "gcs" ]] ; then
    -	type gsutil >/dev/null 2>&1
    -	RET=$?
    -	if [[ ${RET} -eq 0 ]]; then
    +	GCS_BUCKET="$( settings ".${PREFIX}_GCS_BUCKET" "${ALLSKY_ENV}" )"
    +	GCS_ACL="$( settings ".${PREFIX}_GCS_ACL" "${ALLSKY_ENV}" )"
    +
    +	if type gsutil >/dev/null 2>&1 ; then
     		DEST="gs://${GCS_BUCKET}${DIRECTORY}/${DESTINATION_NAME}"
     		if [[ ${SILENT} == "false" && ${ALLSKY_DEBUG_LEVEL} -ge 3 ]]; then
     			echo "${ME}: Uploading ${FILE_TO_UPLOAD} to ${DEST}"
    @@ -178,16 +235,18 @@ elif [[ ${PROTOCOL} == "gcs" ]] ; then
     		RET=$?
     	else
     		OUTPUT="${ME}: ERROR: 'gsutil' command not found; cannot upload."
    -		OUTPUT="${OUTPUT}\nIt should be in one of these directories: $PATH"
    +		OUTPUT+="\nIt should be in one of these directories: ${PATH}"
     		"${ALLSKY_SCRIPTS}/addMessage.sh" "error" "${OUTPUT}"
     		OUTPUT="${RED}*** ${OUTPUT}${NC}"
    +		RET=1
     	fi
     
     
     else # sftp/ftp/ftps
     	# "put" to a temp name, then move the temp name to the final name.
    -	# This is useful with slow uplinks where multiple lftp requests can be running at once,
    -	# and only one lftp can upload the file at once, otherwise we get this error:
    +	# This is useful with slow uplinks where multiple upload requests can be running at once,
    +	# and only one upload can upload the file at once.
    +	# For lftp we get this error:
     	#	put: Access failed: 550 The process cannot access the file because it is being used by
     	#		another process. (image.jpg)
     	# Slow uplinks also cause problems with web servers that read the file as it's being uploaded.
    @@ -197,20 +256,48 @@ else # sftp/ftp/ftps
     
     	TEMP_NAME="${FILE_TYPE}-${RANDOM}"
     
    -	# If DIRECTORY isn't null (which it can be) and doesn't already have a trailing "/", append one.
    -	[[ -n ${DIRECTORY} && ${DIRECTORY: -1:1} != "/" ]] && DIRECTORY="${DIRECTORY}/"
    -
    -	if [[ ${SILENT} == "false" && ${ALLSKY_DEBUG_LEVEL} -ge 3 ]]; then
    -		echo "${ME}: FTP '${FILE_TO_UPLOAD}' to '${DIRECTORY}${DESTINATION_NAME}', TEMP_NAME=${TEMP_NAME}"
    +	# If directory is null (which it can be) put the file in the image directory
    +	# which is the root.
    +	if [[ -z ${DIRECTORY} ]]; then
    +		IMAGE_DIR="$( settings ".${prefix}imagedir" )"
    +		if [[ -n ${IMAGE_DIR} ]]; then
    +			[[ ${IMAGE_DIR: -1:1} != "/" ]] && IMAGE_DIR+="/"
    +			DIRECTORY="${IMAGE_DIR}"
    +		fi
    +		
    +	elif [[ ${DIRECTORY: -1:1} != "/" ]]; then
    +		# If DIRECTORY doesn't already have a trailing "/", append one.
    +		DIRECTORY+="/"
     	fi
    +
     	# LFTP_CMDS needs to be unique per file type so we don't overwrite a different upload type.
     	DIR="${ALLSKY_TMP}/lftp_cmds"
    -	[[ ! -d ${DIR} ]] && mkdir "${DIR}"
    +	if [[ ! -d ${DIR} ]]; then
    +		if ! mkdir "${DIR}" ; then
    +			echo -e "${RED}"
    +			echo -e "*** ERROR: Unable to create '${DIR}'."
    +			echo -e "${NC}"
    +			ls -ld "${ALLSKY_TMP}"
    +			exit 1
    +		fi
    +	fi
     	LFTP_CMDS="${DIR}/${FILE_TYPE}.txt"
     
     	set +H	# This keeps "!!" from being processed in REMOTE_PASSWORD
     
    +	REMOTE_USER="$( settings ".${PREFIX}_USER" "${ALLSKY_ENV}" )"
    +	REMOTE_HOST="$( settings ".${PREFIX}_HOST" "${ALLSKY_ENV}" )"
    +	REMOTE_PORT="$( settings ".${PREFIX}_PORT" "${ALLSKY_ENV}" )"
     	# The export LFTP_PASSWORD has to be OUTSIDE the ( ) below.
    +	REMOTE_PASSWORD="$( settings ".${PREFIX}_PASSWORD" "${ALLSKY_ENV}" )"
    +
    +	if [[ ${SILENT} == "false" && ${ALLSKY_DEBUG_LEVEL} -ge 3 ]]; then
    +		MSG="${ME}: FTP '${FILE_TO_UPLOAD}' to ${REMOTE_USER} @ ${REMOTE_HOST}"
    +		MSG+=" '${DIRECTORY}${DESTINATION_NAME}'"
    +		[[ ${ALLSKY_DEBUG_LEVEL} -ge 4 ]] && MSG+=", TEMP_NAME=${TEMP_NAME}"
    +		echo "${MSG}"
    +	fi
    +
     	if [[ ${DEBUG} == "true" ]]; then
     		# In debug mode, include the password on the command line so it's easier
     		# for the user to run "lftp -f ${LFT_CMDS}"
    @@ -222,7 +309,12 @@ else # sftp/ftp/ftps
     		PW="--env-password"
     	fi
     
    -	(
    +	{
    +		if [[ ${DEBUG} == "true" ]]; then
    +			echo "debug 5"
    +		fi
    +
    +		LFTP_COMMANDS="$( settings ".${PREFIX}_LFTP_COMMANDS" "${ALLSKY_ENV}" )"
     		[[ -n ${LFTP_COMMANDS} ]] && echo "${LFTP_COMMANDS}"
     
     		# Sometimes have problems with "max-reties 1", so make it 2
    @@ -245,7 +337,6 @@ else # sftp/ftp/ftps
     			# but if it works it returns "xxx is current directory" so only output that.
     			echo "quote PWD | grep current "
     			echo "ls"
    -			echo "debug 5"
     		fi
     		if [[ -n ${DIRECTORY} ]]; then
     			# lftp outputs error message so we don't have to.
    @@ -282,13 +373,23 @@ else # sftp/ftp/ftps
     			|| exit 4"
     
     		echo exit 0
    -	) > "${LFTP_CMDS}"
    +	} > "${LFTP_CMDS}"
    +	if [[ $? -ne 0 ]]; then
    +		echo -e "${RED}"
    +		echo -e "*** ERROR: Unable to create '${LFTP_CMDS}'."
    +		echo -e "${NC}"
    +		# Do ls of parent and grandparent.
    +		PARENT="$( dirname "${LFTP_CMDS}" )"
    +		GRANDPARENT="$( dirname "${PARENT}" )"
    +		ls -ld "${PARENT}" "${GRANDPARENT}"
    +		exit 1
    +	fi
     
     	OUTPUT="$( lftp -f "${LFTP_CMDS}" 2>&1 )"
     	RET=$?
     	if [[ ${RET} -ne 0 ]]; then
     		HEADER="${RED}*** ${ME}: ERROR,"
    -		if [[ ${RET} -eq ${ALLSKY_ERROR_STOP} ]]; then
    +		if [[ ${RET} -eq ${EXIT_ERROR_STOP} ]]; then
     			# shellcheck disable=SC2153
     			OUTPUT="$(
     				echo "${HEADER} unable to log in to '${REMOTE_HOST}', user ${REMOTE_USER}'."
    @@ -311,29 +412,13 @@ else # sftp/ftp/ftps
     
     		echo -e "\n${YELLOW}Commands used${NC} are in: ${GREEN}${LFTP_CMDS}${NC}"
     	else
    -		if [[ ${SILENT} == "false" && ${ALLSKY_DEBUG_LEVEL} -ge 4 && ${ON_TTY} -eq 0 ]]; then
    +		if [[ ${SILENT} == "false" && ${ALLSKY_DEBUG_LEVEL} -ge 3 && ${ON_TTY} == "false" ]]; then
     			echo "${ME}: FTP '${FILE_TO_UPLOAD}' finished"
     		fi
     	fi
     fi
     
    -# Output any error messages
    -if [[ -n ${OUTPUT} ]]; then
    -	echo -e "Upload output from '${FILE_TO_UPLOAD}:\n   ${OUTPUT}\n" >&2
    -	echo -e "${OUTPUT}" > "${LOG}"
    -fi
    -
    -# If a local directory was also specified, copy the file there.
    -if [[ -n ${COPY_TO} ]]; then
    -	if [[ ${SILENT} == "false" && ${ALLSKY_DEBUG_LEVEL} -ge 3 ]]; then
    -		# No need to specify the file being copied again since we did so above.
    -		echo "${ME}: Also copying to ${COPY_TO}/${DESTINATION_NAME}"
    -	fi
    -	cp "${FILE_TO_UPLOAD}" "${COPY_TO}/${DESTINATION_NAME}"
    -	((RET=RET + $?))
    -fi
    -
    +check_for_error_messages "${OUTPUT}"
     rm -f "${PID_FILE}"
     
    -# shellcheck disable=SC2086
    -exit ${RET}
    +exit "${RET}"
    diff --git a/scripts/utilities/allsky-config.sh b/scripts/utilities/allsky-config.sh
    new file mode 100755
    index 000000000..f102c7341
    --- /dev/null
    +++ b/scripts/utilities/allsky-config.sh
    @@ -0,0 +1,319 @@
    +#!/bin/bash
    +
    +# This scripts is similar to "raspi-config" but for Allsky.
    +# It's a command-line method to view and set certain Allsky items.
    +
    +# Allow this script to be executed manually, which requires several variables to be set.
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
    +ME="$( basename "${BASH_ARGV0}" )"
    +
    +#shellcheck source-path=.
    +source "${ALLSKY_HOME}/variables.sh"					|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/functions.sh"					|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/installUpgradeFunctions.sh"	|| exit "${EXIT_ERROR_STOP}"
    +
    +# allow user to select additional commands after 1st one?
    +ALLOW_MORE_COMMANDS="true"
    +
    +OK="true"
    +DO_HELP="false"
    +CMD=""
    +CMD_ARGS=""
    +DEBUG="false"
    +
    +while [[ $# -gt 0 ]]; do
    +	ARG="${1}"
    +	case "${ARG,,}" in
    +		--help)
    +			DO_HELP="true"
    +			;;
    +
    +		--debug)
    +			DEBUG="true"
    +			;;
    +
    +		-*)
    +			echo -e "${RED}${ME}: Unknown argument '${ARG}'.${NC}" >&2
    +			OK="false"
    +			;;
    +
    +		*)
    +			CMD="${ARG}"
    +			shift
    +			CMD_ARGS="${@}"
    +			break;
    +			;;
    +	esac
    +	shift
    +done
    +
    +function usage_and_exit()
    +{
    +	local RET=${1}
    +	{
    +		echo
    +		[[ ${RET} -ne 0 ]] && echo -en "${RED}"
    +		echo "Usage: ${ME} [--help] [--debug] [command [--help] [arguments ...]]"
    +		[[ ${RET} -ne 0 ]] && echo -en "${NC}"
    +		echo -e "\n	where:"
    +		echo -e "	'--help' displays this message and exits."
    +		echo -e "	'--debug' displays debugging information."
    +		echo -e "	'command' is a command to execute with optional arguments.  Choices are:"
    +		echo -e "		show_supported_cameras  RPi | ZWO"
    +		echo -e "		show_connected_cameras"
    +		echo -e "		recheck_swap"
    +		echo -e "		recheck_tmp"
    +		echo -e "		samba"
    +		echo -e "		new_rpi_camera_info [--camera NUM]"
    +		echo -e "		show_start_times [--zero] [angle [latitude [longitude]]]"
    +		echo -e "	If no 'command' is specified you are prompted for one to execute."
    +	} >&2
    +	exit "${RET}"
    +}
    +
    +[[ ${DO_HELP} == "true" ]] && usage_and_exit 0
    +[[ ${OK} == "false" ]] && usage_and_exit 1
    +PATH="${PATH}:${ALLSKY_UTILITIES}"
    +
    +
    +####################################### Functions - one per command
    +
    +#####
    +# Show all the supported cameras.
    +function show_supported_cameras()
    +{
    +	local ARGS="${@}"
    +
    +	#shellcheck disable=SC2086
    +	if needs_arguments ${ARGS} ; then
    +		PROMPT="\nSelect the camera(s) to show:"
    +		OPTS=()
    +		OPTS+=("--RPi"			"RPi and compatible")
    +		OPTS+=("--ZWO"			"ZWO (very long list)")
    +		OPTS+=("--RPi --ZWO"	"both")
    +
    +		# If the user selects "Cancel" prompt() returns 1 and we exit the loop.
    +		ARGS="$( prompt "${PROMPT}" "${OPTS[@]}" )"
    +	fi
    +
    +	#shellcheck disable=SC2086
    +	show_supported_cameras.sh ${ARGS}
    +}
    +
    +#####
    +# Show all the currently connected cameras.
    +function show_connected_cameras()
    +{
    +	get_connected_cameras_info "true" > "${CONNECTED_CAMERAS_INFO}"
    +
    +	local CAMERAS="$( get_connected_camera_models --full "both" )"
    +	if [[ -z ${CAMERAS} ]]; then
    +		echo -e "\nThere are no cameras connected to the Pi."
    +	else
    +		local FORMAT="%-6s %-8s %s\n"
    +		echo
    +		printf "${FORMAT}" "Type" "Number" "Model"
    +		printf "${FORMAT}" "====" "======" "====="
    +		echo -e "${CAMERAS}" | while read -r TYPE NUM MODEL
    +			do
    +				printf "${FORMAT}" "${TYPE}" "${NUM}" "${MODEL}"
    +			done
    +	fi
    +}
    +
    +#####
    +# Show all the currently connected cameras.
    +function new_rpi_camera_info()
    +{
    +	local ARGS="${@}"		# optional
    +
    +	#shellcheck disable=SC2086
    +	get_RPi_camera_info.sh ${ARGS}
    +}
    +
    +#####
    +# Install SAMBA.
    +function samba()
    +{
    +	installSamba.sh
    +}
    +
    +#####
    +# Show the daytime and nighttime start times
    +function show_start_times()
    +{
    +	local DO_HELP="false"
    +	local ZERO=""
    +	local ANGLE=""
    +	local LATITUDE=""
    +	local LONGITUDE=""
    +
    +	while [[ $# -gt 0 ]]; do
    +		ARG="${1}"
    +		case "${ARG,,}" in
    +			--help)
    +				DO_HELP="true"
    +				;;
    +
    +			--zero)
    +				ZERO="${ARG}"
    +				;;
    +
    +			--angle)
    +				ANGLE="${2}"
    +				shift
    +				;;
    +
    +			--latitude)
    +				LATITUDE="${2}"
    +				shift
    +				;;
    +
    +			--longitude)
    +				LONGITUDE="${2}"
    +				shift
    +				;;
    +
    +			-*)
    +				echo -e "${RED}${ME}: Unknown argument '${ARG}'.${NC}" >&2
    +				OK="false"
    +				;;
    +		esac
    +		shift
    +	done
    +
    +	if [[ ${DO_HELP} == "true" ]]; then
    +		echo
    +		echo "Usage: ${ME}  ${FUNCNAME[0]} [--zero]  [ --angle A]  [--latitude LAT]  [--longitude LONG]"
    +		echo "Where:"
    +		echo "    '--zero' will also show times for Angle 0."
    +		echo "By default, the Angle, Latitude, and Longitude in the WebUI will be use."
    +		echo "You can override any of those via the command line."
    +		echo
    +
    +		return
    +	fi
    +
    +	#shellcheck disable=SC2086
    +	get_sunrise_sunset ${ZERO} "${ANGLE}" "${LATITUDE}" "${LONGITUDE}"
    +}
    +
    +#####
    +# recheck_tmp and recheck_swap are functions defined elsewhere.
    +
    +####################################### Helper functions
    +
    +# Check if the required argument(s) were given to this command.
    +# If called via the command line it's an error if no arguments
    +# were given, so exit since we can't prompt (we may be called by another program).
    +# If called via a menu item there normally WON'T be an argument so
    +# return 0 which tells the caller it needs to prompt for the arguments.
    +function needs_arguments()
    +{
    +	if [[ $# -eq 0 ]]; then
    +		if [[ -n ${CMD} ]]; then		# CMD is global
    +			echo -e "\n${RED}'${FUNCNAME[1]}' requires an argument.${NC}" >&2
    +			usage_and_exit 1
    +		else
    +			echo "${@}"
    +		fi
    +
    +		return 0
    +	else
    +		return 1
    +	fi
    +}
    +
    +#####
    +# Run a command / function, passing any arguments.
    +function run_command()
    +{
    +	COMMAND="${1}"
    +	shift
    +	ARGUMENTS="${@}"
    +	if ! type "${COMMAND}" > /dev/null 2>&1 ; then
    +		echo -e "\n${RED}${ME}: Unknown command '${COMMAND}'.${NC}" >&2
    +		return 1
    +	fi
    +
    +	if [[ ${DEBUG} == "true" ]]; then
    +		echo -e "${YELLOW}"
    +		echo -e "Executing: ${COMMAND} ${ARGUMENTS}:\n"
    +		echo -e "${NC}"
    +	fi
    +
    +	#shellcheck disable=SC2086
    +	"${COMMAND}" ${ARGUMENTS}
    +}
    +
    +
    +#####
    +# Prompt for a command or argument
    +function prompt()
    +{
    +	PROMPT="${1}"
    +	shift
    +	OPTIONS=("${@}")
    +
    +	TITLE="*** Allsky Configuration ***"
    +	NUM_OPTIONS=$(( (${#OPTIONS[@]} / 2) + 3 ))
    +
    +	OPT="$( whiptail --title "${TITLE}" --notags --menu "${PROMPT}" \
    +		18 "${WT_WIDTH:-80}" "${NUM_OPTIONS}" -- "${OPTIONS[@]}" 3>&1 1>&2 2>&3 )"
    +	RET=$?
    +	if [[ ${RET} -eq 255 ]]; then
    +		echo -e "\n${RED}${ME}: whiptail failed.${NC}" >&2
    +		exit 2
    +	else
    +		echo "${OPT}"
    +		return ${RET}
    +	fi
    +}
    +
    +
    +####################################### Main part of program
    +
    +if [[ -z ${CMD} ]]; then
    +	# No command given on command line so prompt for one.
    +
    +	PROMPT="\nSelect a command to run:"
    +	CMDS=()
    +	CMDS+=("show_supported_cameras"		"1. Show supported cameras")
    +	CMDS+=("show_connected_cameras"		"2. Show connected cameras")
    +	CMDS+=("recheck_swap"				"3. Add swap space")
    +	CMDS+=("recheck_tmp"				"4. Move ~/allsky/tmp to memory")
    +	CMDS+=("samba"						"5. Simplify copying files to/from the Pi")
    +	CMDS+=("new_rpi_camera_info"		"6. Collect information for new RPi camera")
    +	CMDS+=("show_start_times"	 		"7. Show daytime and nighttime start times")
    +
    +	# If the user selects "Cancel" prompt() returns 1 and we exit the loop.
    +	while COMMAND="$( prompt "${PROMPT}" "${CMDS[@]}" )"
    +	do
    +		run_command "${COMMAND}"
    +		RET=$?
    +
    +		[[ ${ALLOW_MORE_COMMANDS} == "false" ]] && exit ${RET}
    +		while true; do
    +			echo -e "\n\n"
    +			echo -e "${YELLOW}${BOLD}"
    +			echo    "=========================================="
    +			echo -n "Press RETURN to continue or 'q' to quit: "
    +			read -r x
    +			echo -e "${NC}"
    +			[[ ${x:0:1} == "q" ]] && exit 0
    +			if [[ -n ${x} ]]; then
    +				echo "'${x}' is not a valid response; try again."
    +			else
    +				break
    +			fi
    +		done
    +	done
    +	exit 0
    +
    +else
    +	#shellcheck disable=SC2086
    +	run_command "${CMD}" ${CMD_ARGS}
    +	exit $?
    +fi
    diff --git a/scripts/utilities/getChecksum.php b/scripts/utilities/getChecksum.php
    new file mode 100755
    index 000000000..87173cb16
    --- /dev/null
    +++ b/scripts/utilities/getChecksum.php
    @@ -0,0 +1,12 @@
    +#!/usr/bin/php
    +
    +<?php
    +// Determine the checksum for the files listed on stdin.
    +while (($file = fgets(STDIN)) !== false) {
    +	if (! file_exists($file))
    +		continue;
    +
    +	$c = crc32($file);
    +	echo "$c\t$file";
    +}
    +?>
    diff --git a/scripts/utilities/get_RPi_camera_info.sh b/scripts/utilities/get_RPi_camera_info.sh
    new file mode 100755
    index 000000000..b2973fb79
    --- /dev/null
    +++ b/scripts/utilities/get_RPi_camera_info.sh
    @@ -0,0 +1,156 @@
    +#!/bin/bash
    +
    +# This script gets information on any attached RPi camera(s),
    +# primarily to be used when requesting support for a new camera.
    +
    +# Allow this script to be executed manually, which requires several variables to be set.
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
    +ME="$( basename "${BASH_ARGV0}" )"
    +
    +#shellcheck source-path=.
    +source "${ALLSKY_HOME}/variables.sh"		|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${EXIT_ERROR_STOP}"
    +
    +OK="true"
    +DO_HELP="false"
    +CAMERA_NUMBER=0
    +while [[ $# -gt 0 ]]; do
    +	ARG="${1}"
    +	case "${ARG,,}" in
    +		--help)
    +			DO_HELP="true"
    +			;;
    +		--camera)
    +			CAMERA_NUMBER="$2"
    +			shift
    +			;;
    +		-*)
    +			echo -e "${RED}Unknown argument '${ARG}'.${NC}" >&2
    +			OK="false"
    +			;;
    +	esac
    +	shift
    +done
    +
    +usage_and_exit()
    +{
    +	local RET=${1}
    +	{
    +		echo
    +		[[ ${RET} -ne 0 ]] && echo -en "${RED}"
    +		echo "Usage: ${ME} [--help] [--camera NUM]"
    +		[[ ${RET} -ne 0 ]] && echo -en "${NC}"
    +		echo "    where:"
    +		echo "      '--help' displays this message and exits."
    +		echo "      '--camera NUM' use camera number NUM."
    +		exit "${RET}"
    +	} >&2
    +}
    +
    +[[ ${DO_HELP} == "true" ]] && usage_and_exit 0
    +[[ ${OK} == "false" ]] && usage_and_exit 1
    +
    +CMD="$( determineCommandToUse "false" "" "false" )"		|| exit 1
    +if [[ ${CMD} == "raspistill" ]]; then
    +	echo "${ME} does not work with raspistill." >&2
    +	exit 1
    +fi
    +
    +export LIBCAMERA_LOG_LEVELS="ERROR,FATAL"
    +CAMERA_DATA="${ALLSKY_TMP}/camera_data.txt"
    +{
    +	echo -e "===== ${CMD} --list-cameras"
    +	"${CMD}" --list-cameras 2>&1
    +} > "${CAMERA_DATA}"
    +
    +if [[ $( wc --lines < "${CAMERA_DATA}" ) -le 2 ]]; then
    +	echo "${ME}: No RPi cameras found!" >&2
    +	exit 1
    +fi
    +
    +
    +# CAMERA_DATA's format:
    +#	0 : imx477 [4056x3040] (/base/soc/i2c0mux/i2c@1/imx477@1a)
    +#	    Modes: 'SRGGB10_CSI2P' : 1332x990 
    +#	           'SRGGB12_CSI2P' : 2028x1080 2028x1520 4056x3040 
    +
    +SENSOR="$( grep "${CAMERA_NUMBER} :" "${CAMERA_DATA}" | gawk '{ print $3; }' )"
    +
    +# Determine if this sensor is supported.
    +MODEL="$( get_model_from_sensor "${SENSOR}" )"
    +if grep --silent "^camera.*${MODEL}" "${RPi_SUPPORTED_CAMERAS}" ; then
    +	SUPPORTED="true"
    +else
    +	SUPPORTED="false"
    +fi
    +
    +{
    +	echo -e "\n===== ${CMD}"
    +	# Do not use --immediate 1 since it causes the max Exposure time to be 0.
    +	"${CMD}" --camera "${CAMERA_NUMBER}"  -v --metadata - --immediate 0 --nopreview \
    +		--thumb none --timeout 1 --shutter 1 --output /dev/null
    +	RET=$?
    +} >> "${CAMERA_DATA}" 2>&1
    +if [[ ${RET} -ne 0 ]]; then
    +	echo "${ME}: '${CMD}' failed:"
    +	indent "$( "${CAMERA_DATA}" )"
    +	exit "${RET}"
    +fi
    +
    +
    +# Example output:
    +#	ExposureTime : [114..674191602]
    +#	FrameDurationLimits : [100000..694434742]
    +MAX_EXPOSURE="$( grep -E "ExposureTime :|FrameDurationLimits :" "${CAMERA_DATA}" |
    +	tail -2 | sed -e 's/://' -e 's/\.\./ /' -e 's/]//' |
    +	gawk '
    +		BEGIN { et = 0; fdl = 0; }
    +		{
    +			if ($1 == "ExposureTime") {
    +				et = $3;
    +			} else if ($1 == "FrameDurationLimits") {
    +				fdl = $3;
    +			}
    +		}
    +		END {
    +			if (et == 0 && fdl == 0) {
    +				exit 1;
    +			}
    +			if (et == 0 || et > fdl)
    +				num = et;
    +			else
    +				num = fdl;
    +			printf("%.1f\n", num / 1000000);
    +			exit(0);
    +		}'
    +)"
    +RET=$?
    +
    +echo
    +if [[ ${RET} -eq 0 ]]; then
    +	if [[ ${SUPPORTED} == "true" ]] ; then
    +		echo "Camera model ${MODEL}, sensor ${SENSOR} is supported by Allsky."
    +		echo "Information about the camera's features is in:"
    +		echo "    ${CAMERA_DATA}"
    +	else
    +		echo "Maximum exposure time for sensor '${SENSOR}' is ${MAX_EXPOSURE} seconds."
    +		if gawk -v E="${MAX_EXPOSURE}" 'BEGIN { if (E >= 60) exit 0; else exit 1; }' ; then
    +			echo ">>> This will make a good allsky camera. <<<"
    +		else
    +			echo ">>> This is a short maximum exposure so may not make a good allsky camera. <<<"
    +		fi
    +
    +		echo
    +		echo "************************"
    +		echo "When requesting support for this camera, please attach"
    +		echo "    ${CAMERA_DATA}"
    +		echo "to your request."
    +		echo    "************************"
    +	fi
    +else
    +	echo "${ME}: ERROR: Unable to determine maximum exposure time for camera ${MODEL} ${SENSOR}." >&2
    +fi
    +echo
    +
    +exit "${RET}"
    diff --git a/scripts/utilities/get_model_from_sensor.sh b/scripts/utilities/get_model_from_sensor.sh
    new file mode 100755
    index 000000000..10b7cc48c
    --- /dev/null
    +++ b/scripts/utilities/get_model_from_sensor.sh
    @@ -0,0 +1,16 @@
    +#!/bin/bash
    +
    +# Small script to call the get_model_from_sensor() function.
    +# It's needed so programs can call the function.
    +
    +# Allow this script to be executed manually, which requires several variables to be set.
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
    +ME="$( basename "${BASH_ARGV0}" )"
    +
    +#shellcheck source-path=.
    +source "${ALLSKY_HOME}/variables.sh"		|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${EXIT_ERROR_STOP}"
    +
    +SENSOR="${1}"
    +get_model_from_sensor "${SENSOR}"
    diff --git a/scripts/utilities/installSamba.sh b/scripts/utilities/installSamba.sh
    new file mode 100755
    index 000000000..ce8f82feb
    --- /dev/null
    +++ b/scripts/utilities/installSamba.sh
    @@ -0,0 +1,165 @@
    +#!/bin/bash
    +
    +# Install SAMBA to enable network access to/from another device.
    +# Base idea from StackExchange ( https://bit.ly/3Qqzbnp )
    +
    +# Allow this script to be executed manually, which requires several variables to be set.
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
    +ME="$( basename "${BASH_ARGV0}" )"
    +
    +# shellcheck source-path=..
    +source "${ALLSKY_HOME}/variables.sh"	|| exit 1
    +
    +if [[ -z ${LOGNAME} ]]; then
    +	echo "${RED}${ME}: Unknown LOGNAME; cannot continue.${NC}" >&2
    +	exit 1
    +fi
    +
    +
    +OK="true"
    +while [[ $# -gt 0 ]]; do
    +	ARG="${1}"
    +	case "${ARG,,}" in
    +		--help)
    +			DO_HELP="true"
    +			;;
    +# TODO: allow some customization like SHARE_NAME
    +		-*)
    +			echo -e "${RED}Unknown argument '${ARG}' ignoring.${NC}" >&2
    +			OK="false"
    +			;;
    +	esac
    +	shift
    +done
    +
    +usage_and_exit()
    +{
    +	local RET=${1}
    +	{
    +		echo
    +		[[ ${RET} -ne 0 ]] && echo -en "${RED}"
    +		echo "Usage: ${ME} [--help]"
    +		[[ ${RET} -ne 0 ]] && echo -en "${NC}"
    +		echo "    where:"
    +		echo "      '--help' displays this message and exits."
    +	} >&2
    +	exit "${RET}"
    +}
    +[[ ${DO_HELP} == "true" ]] && usage_and_exit 0
    +[[ ${OK} == "false" ]] && usage_and_exit 1
    +
    +CAP="${LOGNAME:0:1}"
    +CAP="${CAP^^}${LOGNAME:1}"
    +SHARE_NAME="${SHARE_NAME:-${LOGNAME}_home}"
    +
    +# Check if SAMBA is already installed and configured
    +CONFIG_FILE="/etc/samba/smb.conf"
    +if [[ -f ${CONFIG_FILE} ]] && grep --silent "\[${SHARE_NAME}]" "${CONFIG_FILE}" ; then
    +	echo -e "\n${YELLOW}"
    +	echo "*************"
    +	echo "Your Pi is already configured to share files with other network devices"
    +	echo "using the '${SHARE_NAME}' share."
    +	echo -e "${NC}"
    +	exit 0
    +fi
    +
    +echo -e "\n${YELLOW}"
    +echo "*************"
    +echo "This script will install SAMBA which lets remote devices mount"
    +echo "your Pi as a network drive."
    +echo "The '${HOME}' directory on the Pi will appear as '${SHARE_NAME}' on remote devices."
    +echo "You can then copy files to and from the Pi as you would from any other drive."
    +echo
    +echo "When installation is done you will be prompted for a SAMBA password."
    +echo
    +echo -en "${BOLD}"
    +echo    "============================================="
    +echo -n "Press RETURN to continue with installation: "
    +read -r x
    +echo -e "${NC}"
    +
    +
    +# Install SAMBA 
    +mkdir -p "${ALLSKY_LOGS}"
    +LOG="${ALLSKY_LOGS}/SAMBA.log"
    +
    +echo -e "${GREEN}..... Installing SAMBA.${NC}"
    +sudo apt install samba -y > "${LOG}" 2>&1
    +if [[ $? -ne 0 ]]; then
    +	echo -e "\n${RED}"
    +	echo "Installation of SAMBA failed:"
    +	echo "$( < "${LOG}" )"
    +	echo -e "${NC}"
    +	exit 1
    +fi
    +
    +# Add the user to SAMBA and prompt for their SAMBA password.
    +echo -e "\n${YELLOW}"
    +echo "You will be prompted for a SAMBA password which remote machines will use to"
    +echo "map to your Pi's drive."
    +echo "This is a different password than ${LOGNAME}'s password or the root password,"
    +echo "although you may elect to make them the same."
    +echo
    +echo "If this is your first time installing SAMBA on this Pi and"
    +echo "you are prompted for a CURRENT password, echo press 'Enter'."
    +echo "*************"
    +echo -e "${NC}"
    +sudo smbpasswd -a "${LOGNAME}"			|| exit 1
    +
    +WORKGROUP="WORKGROUP"
    +echo -e "${GREEN}..... Configuring SAMBA.${NC}"
    +
    +sudo mv -f "${CONFIG_FILE}" "${CONFIG_FILE}.bak"
    +
    +sudo tee "${CONFIG_FILE}" > /dev/null <<EOF
    +### Config File ###
    +
    +[global]
    +workgroup = ${WORKGROUP}
    +server role = standalone server
    +obey pam restrictions = no
    +map to guest = never
    +
    +client min protocol = SMB2
    +client max protocol = SMB3
    +vfs objects = catia fruit streams_xattr
    +fruit:metadata = stream
    +fruit:model = RackMac
    +fruit:posix_rename = yes
    +fruit:veto_appledouble = no
    +fruit:wipe_intentionally_left_blank_rfork = yes
    +fruit:delete_empty_adfiles = yes
    +security = user
    +encrypt passwords = yes
    +
    +# Optional logging.  Is very verbose.
    +# log file = /var/log/samba/log.%m
    +# max log size = 1000
    +# logging = file
    +
    +# The directories you want accessible by other devices.
    +# Each one's name must be surrounded by [].
    +
    +[${SHARE_NAME}]
    +comment = ${CAP} home directory
    +path = ${HOME}
    +browseable = yes
    +read only = no
    +create mask = 0664
    +directory mask = 0775
    +
    +### end Config ###
    +EOF
    +
    +echo -e "${GREEN}..... Restarting SAMBA.${NC}"
    +sudo /etc/init.d/smbd restart
    +
    +echo -e "${YELLOW}"
    +echo "*************"
    +echo "You can now mount '${SHARE_NAME}' on your remote device using"
    +echo "workgroup '${WORKGROUP}' and login name '${LOGNAME}'."
    +echo "If you don't know how to do that, see your remote device's operating system documentation."
    +echo "*************"
    +echo -e "${NC}"
    +
    +exit 0
    diff --git a/scripts/utilities/show_supported_cameras.sh b/scripts/utilities/show_supported_cameras.sh
    new file mode 100755
    index 000000000..9e7ce6db7
    --- /dev/null
    +++ b/scripts/utilities/show_supported_cameras.sh
    @@ -0,0 +1,103 @@
    +#!/bin/bash
    +
    +# This scripts outputs the list of cameras Allsky supports.
    +
    +# Allow this script to be executed manually, which requires several variables to be set.
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )/.." )"
    +ME="$( basename "${BASH_ARGV0}" )"
    +
    +#shellcheck source-path=.
    +source "${ALLSKY_HOME}/variables.sh"		|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit "${EXIT_ERROR_STOP}"
    +
    +OK="true"
    +DO_HELP="false"
    +DO_ZWO="false"
    +DO_RPI="false"
    +while [[ $# -gt 0 ]]; do
    +	ARG="${1}"
    +	case "${ARG,,}" in
    +		--help)
    +			DO_HELP="true"
    +			;;
    +		--rpi)
    +			DO_RPI="true"
    +			;;
    +		--zwo)
    +			DO_ZWO="true"
    +			;;
    +		-*)
    +			echo -e "${RED}Unknown argument '${ARG}' ignoring.${NC}" >&2
    +			OK="false"
    +			;;
    +	esac
    +	shift
    +done
    +
    +usage_and_exit()
    +{
    +	local RET=${1}
    +	{
    +		echo
    +		[[ ${RET} -ne 0 ]] && echo -en "${RED}"
    +		echo "Usage: ${ME} [--help] --rpi | --zwo"
    +		[[ ${RET} -ne 0 ]] && echo -en "${NC}"
    +		echo "    where:"
    +		echo "      '--help' displays this message and exits."
    +		echo "      '--rpi' displays a list of supported Raspberry Pi and compatible cameras."
    +		echo "      '--zwo' displays a list of supported ZWO cameras."
    +	} >&2
    +	exit "${RET}"
    +}
    +
    +[[ ${DO_HELP} == "true" ]] && usage_and_exit 0
    +[[ ${OK} == "false" ]] && usage_and_exit 1
    +if [[ ${DO_RPI} == "false" && ${DO_ZWO} == "false" ]]; then
    +	echo -e "${RED}You must specify --rpi and/or --zwo${NC}" >&2
    +	usage_and_exit 2
    +fi
    +
    +
    +if [[ ${DO_RPI} == "true" ]]; then
    +	[[ ${DO_ZWO} == "true" ]] && echo -e "===== Supported RPi cameras:"
    +	# Format of input file:
    +	#	camera  sensor  compare_length  model  other_info_for_camera_1
    +	#	libcamera libcamera_camera_capability_line_1
    +	#	libcamera libcamera_camera_capability_line_2
    +	#	libcamera End
    +	#	raspistill raspistill_camera_capability_line_1
    +	#	raspistill raspistill_camera_capability_line_2
    +	#	raspistill End
    +	#	camera  sensor  compare_length  model  other_info_for_camera_2
    +	#	...
    +	gawk -F'\t' '
    +		BEGIN {
    +			printf("%-25s Sensor\n", "Camera Name");
    +			printf("%-25s-------\n", "--------------------------");
    +		}
    +		{
    +			if ($1 == "camera") {
    +				sensor = $2;
    +				compare_length = $3
    +				model = $4;
    +				if (compare_length > 0)
    +					other = " and related sensors";
    +				else
    +					other = "";
    +				printf("%-25s %s%s\n", model, sensor, other);
    +			}
    +		}' "${RPi_SUPPORTED_CAMERAS}"
    +fi
    +
    +if [[ ${DO_ZWO} == "true" ]]; then
    +	[[ ${DO_RPI} == "true" ]] && echo -e "\n===== Supported ZWO cameras:"
    +
    +	# Any of the libraries should work.
    +	strings "${ALLSKY_HOME}/src/lib/armv7/libASICamera2.a" |
    +		grep '_SetResolutionEv$' | \
    +		sed -e 's/^.*CameraS//' -e 's/17Cam//' -e 's/_SetResolutionEv//' | \
    +		sort -u
    +fi
    +
    +exit 0
    diff --git a/src/ASI_functions.cpp b/src/ASI_functions.cpp
    index 31c2e581c..c474a6488 100644
    --- a/src/ASI_functions.cpp
    +++ b/src/ASI_functions.cpp
    @@ -9,6 +9,192 @@
     
     // Forward definitions of variables in capture*.cpp.
     extern int iNumOfCtrl;
    +char *skipType(char *);
    +
    +#define CC_TYPE_SIZE		10
    +#define CAMERA_NAME_SIZE	64
    +#define	MODULE_SIZE			100
    +// +5 for other characters in name
    +#define SENSOR_SIZE			(CAMERA_NAME_SIZE + MODULE_SIZE + 5)
    +#define FULL_SENSOR_SIZE	(2 * SENSOR_SIZE)
    +
    +// Holds connected camera types.
    +struct CONNECTED_CAMERAS
    +{
    +	int cameraID;
    +	char Type[CC_TYPE_SIZE];				// ZWO or RPi
    +	char Name[CAMERA_NAME_SIZE];		// camera name, e.g., "ASI290MC", "HQ"
    +	char Sensor[FULL_SENSOR_SIZE];		// full sensor name (what --list-cameras returns)
    +
    +	// These are RPi only:
    +	char *Module;						// sensor type
    +	size_t Module_len;					// strncmp length.  0 for whole Module name
    +};
    +CONNECTED_CAMERAS connectedCameras[100];
    +int totalNum_connectedCameras = 0;			// num connected cameras of all types
    +
    +char *connectedCameraTypes[100] = {};		// points to connectedCameras.Type
    +int num_connectedCameraTypes = 0;
    +
    +// Find the end of the token that begins at "start".
    +// The end is either "delimeter" or NULL.
    +// If "delimeter", replace with NULL.
    +
    +char *getToken(char *start, char delimeter)
    +{
    +	// "nextToken" points to the place to look for the next token,
    +	// or NULL if we're at the end of the line.
    +	static char *nextToken = NULL;
    +
    +	if (start == NULL || *start == '\0')
    +	{
    +		// If "start" is NULL we're going to start a new line
    +		// so reset "nextToken".
    +		nextToken = NULL;
    +		return(NULL);
    +	}
    +
    +	char *startOfToken;
    +	char *ptr;
    +
    +	if (nextToken == NULL)
    +		ptr = start;
    +	else
    +		ptr = nextToken;
    +
    +	if (*ptr == '\0')
    +		return(NULL);
    +
    +	startOfToken = ptr;
    +
    +	// Find end of token.
    +	while (*ptr != delimeter && *ptr != '\0')
    +	{
    +		ptr++;
    +	}
    +
    +	if (*ptr == '\0')
    +	{
    +		// At the end of the line so reset "nextToken".
    +		nextToken = ptr;
    +	}
    +	else
    +	{
    +		// at delimeter.  Assume there is at least 1 more character in the line.
    +		*ptr = '\0';
    +		nextToken = (ptr+1);
    +	}
    +
    +	return(startOfToken);
    +}
    +
    +// Return the number of cameras PHYSICALLY connected of the correct type.
    +// allsky.sh created the file; we just need to count the number of lines in it.
    +// Also, save information on ALL connected cameras.
    +int getNumOfConnectedCameras()
    +{
    +	// Read the whole file into memory so we can easily parse it.
    +	static char *buf = readFileIntoBuffer(&CG, CG.connectedCamerasFile);
    +	if (buf == NULL)
    +	{
    +		Log(0, "%s: ERROR: Unable to read from CG.RPI_cameraInfoFile '%s': %s\n",
    +			CG.ME, CG.RPI_cameraInfoFile, strerror(errno));
    +		closeUp(EXIT_ERROR_STOP);
    +	}
    +
    +	int numThisType = 0;
    +	char *line;
    +
    +	// Input file format (tab-separated):
    +	//		camera_type  camera_number   sensor_name_or_Model  optional_other_stuff
    +	//		1            2               3                      4
    +	// Sample lines:
    +	// 		RPi          0               imx477                [4056x3040]
    +	// 		ZWO          1               ASI120MC Mini
    +	// ZWO Model names may have multiple words.
    +
    +	int on_line=0;
    +	(void) getLine(NULL);		// resets the buffer pointer
    +	while ((line = getLine(buf)) != NULL)
    +	{
    +		on_line++;
    +		Log(5, "Line %d: [%s]\n", on_line, line);
    +		(void) getToken(NULL, '\t');		// tell getToken() we have a new line.
    +
    +		char *cameraType = getToken(line, '\t');
    +		char *numStr = getToken(line, '\t');
    +		char *cameraModel = getToken(line, '\t');
    +		if (cameraModel != NULL)
    +		{
    +			int num = atoi(numStr);
    +			Log(5, "  cameraType=[%s], num=%d, cameraModel=[%s]\n", cameraType, num, cameraModel);
    +			CONNECTED_CAMERAS *cC = &connectedCameras[totalNum_connectedCameras++];
    +			cC->cameraID = num;
    +			strncpy(cC->Type, cameraType, CC_TYPE_SIZE);
    +#ifdef IS_ZWO
    +			strncpy(cC->Name, cameraModel, CAMERA_NAME_SIZE);
    +			strncpy(cC->Sensor, cameraModel, CAMERA_NAME_SIZE);
    +#else
    +			// cC->Name done later
    +			strncpy(cC->Sensor, cameraModel, SENSOR_SIZE);
    +#endif
    +
    +			if (strcmp(cameraType, CAMERA_TYPE) == 0)
    +				numThisType++;
    +
    +			// Add to the list of connected camera types if not already there.
    +			const char *p;
    +			int n = num_connectedCameraTypes;
    +			for (int i=0; i <= n; i++)
    +			{
    +Log(5, "checking connectedCameraTypes[%d], num_connectedCameraTypes=%d\n", i, n);
    +				if (i == num_connectedCameraTypes)
    +				{
    +					p = "";
    +				}
    +				else
    +				{
    +					p = connectedCameraTypes[i];
    +				}
    +Log(5, "    p is [%s], cameraType=[%s]\n", *p != '\0' ? p : "NOT SET", cameraType);
    +				if (strcmp(p, cameraType) == 0)
    +				{
    +Log(5, "  >> %s already in list; skipping\n", p);
    +					break;
    +				} else if (num_connectedCameraTypes == 0 || strcmp(p, cameraType) != 0)
    +				{
    +					connectedCameraTypes[num_connectedCameraTypes] = cC->Type;
    +					num_connectedCameraTypes++;
    +Log(5, "  NEW TYPE [%s], num_connectedCameraTypes=%d\n", cC->Type, num_connectedCameraTypes);
    +					break;
    +				}
    +			}
    +		}
    +		else
    +		{
    +			// "line" ends with newline
    +			Log(1, "%s: WARNING: skipping invalid line %d in '%s': [%s]",
    +				CG.ME, on_line, basename(CG.connectedCamerasFile), line);
    +		}
    +	}
    +
    +	Log(4, "Connected camera types: %d, connected %s cameras: %d\n",
    +		num_connectedCameraTypes, CAMERA_TYPE, numThisType);
    +
    +#ifdef IS_ZWO
    +	int  ZWOnum = ASIGetNumOfConnectedCameras();
    +	if (ZWOnum != numThisType)
    +	{
    +		Log(0, "%s: ERROR: mismatch with number of ZWO cameras connected: ZWO=%d, other=%d\n",
    +			CG.ME, ZWOnum, numThisType);
    +		closeUp(EXIT_ERROR_STOP);
    +	}
    +#endif
    +
    +	return(numThisType);
    +}
    +
    +
     
     //-----------------------------------------------------------------------------------------
     // Info and routines for RPi only
    @@ -37,28 +223,31 @@ typedef enum ASI_IMG_TYPE {	// Supported Video/Image Formats
     	ASI_IMG_END = -1
     } ASI_IMG_TYPE;
     
    -typedef struct ASI_CAMERA_INFO
    +typedef struct _ASI_CAMERA_INFO
     {
    -	char Module[100];		// sensor type; RPi only
    -	size_t Module_len;		// strncmp length.  0 for whole Module name
    -	char Name[64];			// Name of camera
    +	char Module[MODULE_SIZE];				// sensor name; RPi only
    +	size_t Module_len;						// strncmp length.  0 for whole Module name
    +	char Name[CAMERA_NAME_SIZE];			// Name of camera
     	int CameraID;
    -	long MaxHeight;			// sensor height
    +	long MaxHeight;							// sensor height
     	long MaxWidth;
    -	ASI_BOOL IsColorCam;		// Is this a color camera?
    +	ASI_BOOL IsColorCam;					// Is this a color camera?
     	ASI_BAYER_PATTERN BayerPattern;
     	int SupportedBins[5];	// 1 means bin 1x1 is supported, 2 means 2x2 is supported, etc.
     	ASI_IMG_TYPE SupportedVideoFormat[8];	// Supported image formats
    -	double PixelSize;		// e.g, 5.6 um
    +	double PixelSize;						// e.g, 5.6 um
     	ASI_BOOL IsCoolerCam;
     	int BitDepth;
     	ASI_BOOL SupportsTemperature;
    -	ASI_BOOL SupportsAutoFocus;	// RPi only
    +
    +	// These are RPi only:
    +	ASI_BOOL SupportsAutoFocus;
    +	char Sensor[SENSOR_SIZE];				// full sensor name returned by --list-cameras
     } ASI_CAMERA_INFO;
     
     
     // The number and order of these needs to match argumentNames[]
    -typedef enum ASI_CONTROL_TYPE{ //Control type
    +typedef enum ASI_CONTROL_TYPE{
     	ASI_GAIN = 0,
     	ASI_EXPOSURE,
     	ASI_WB_R,
    @@ -70,10 +259,10 @@ typedef enum ASI_CONTROL_TYPE{ //Control type
     	ASI_AUTO_TARGET_BRIGHTNESS,
     
     	// RPI only:
    +	EV,
     	SATURATION,
     	CONTRAST,
     	SHARPNESS,
    -	EV,
     
     	// Put ZWO ones here - they need to be defined
     	ASI_GAMMA,
    @@ -125,20 +314,20 @@ ASI_CAMERA_INFO ASICameraInfoArray[] =
     	// Module (sensor), Module_len, Name, CameraID, MaxHeight, MaxWidth, IsColorCam,
     		// BayerPattern, SupportedBins, SupportedVideoFormat, PixelSize, IsCoolerCam,
     		// BitDepth, SupportsTemperature, SupportAutoFocus
    -	{ "imx477", 0, "RPi HQ", 0, 3040, 4056, ASI_TRUE,
    +	{ "imx477", 0, "HQ", 0, 3040, 4056, ASI_TRUE,
     		// Need ASI_IMG_END so we know where the end of the list is.
     		BAYER_RG, {1, 2, 0}, {ASI_IMG_RGB24, ASI_IMG_END}, 1.55, ASI_FALSE,
     		12, ASI_TRUE, ASI_FALSE
     	},
     
     	// There are many versions of the imx708 (_wide, _noir, _wide_noir, etc.)
    -	// so just check for "imx708" (6 characters.
    -	{ "imx708", 6, "RPi Module 3", 0, 2592, 4608, ASI_TRUE,
    +	// so just check for "imx708" (6 characters).
    +	{ "imx708", 6, "Module 3", 0, 2592, 4608, ASI_TRUE,
     		BAYER_RG, {1, 2, 0}, {ASI_IMG_RGB24, ASI_IMG_END}, 1.4, ASI_FALSE,
     		10, ASI_TRUE, ASI_TRUE
     	},
     
    -	{ "ov5647", 0, "RPi Version 1", 0, 1944, 2592, ASI_TRUE,
    +	{ "ov5647", 0, "Version 1", 0, 1944, 2592, ASI_TRUE,
     		BAYER_RG, {1, 2, 0}, {ASI_IMG_RGB24, ASI_IMG_END}, 1.4, ASI_FALSE,
     		10, ASI_FALSE, ASI_FALSE
     	},
    @@ -163,6 +352,21 @@ ASI_CAMERA_INFO ASICameraInfoArray[] =
     		10, ASI_FALSE, ASI_TRUE
     	},
     
    +	{ "imx219", 0, "Waveshare imx219-d160", 0, 2464, 3280, ASI_TRUE,
    +		BAYER_RG, {1, 2, 0}, {ASI_IMG_RGB24, ASI_IMG_END}, 1.12, ASI_FALSE,
    +		10, ASI_FALSE, ASI_FALSE
    +	},
    +
    +	{ "ov64a40", 0, "Arducam 64MP Owlsight", 0, 6944, 9248, ASI_TRUE,
    +		BAYER_BG, {1, 2, 0}, {ASI_IMG_RGB24, ASI_IMG_END}, 1.008, ASI_FALSE,
    +		10, ASI_FALSE, ASI_TRUE
    +	},
    +
    +	{ "imx283", 0, "OneInchEye IMX283", 0, 3648, 5472, ASI_TRUE,
    +		BAYER_RG, {1, 2, 0}, {ASI_IMG_RGB24, ASI_IMG_END}, 2.4, ASI_FALSE,
    +		12, ASI_FALSE, ASI_FALSE
    +	},
    +
     	// FUTURE CAMERAS GO HERE...
     };
     int const ASICameraInfoArraySize =  sizeof(ASICameraInfoArray) / sizeof(ASI_CAMERA_INFO);
    @@ -195,11 +399,11 @@ char const *argumentNames[][2] = {
     	{ "Flip", "flip" },
     	{ "AutoExpMaxGain", "maxautogain" },		// day/night
     	{ "AutoExpMaxExpMS", "maxautoexposure" },	// day/night
    -	{ "Brightness", "brightness" },
    +	{ "TargetBrightness", "brightness" },		// not used but keep to be consistent with ZWO
    +	{ "ExposureCompensation", "ev" },
     	{ "Saturation", "saturation" },
     	{ "Contrast", "contrast" },
     	{ "Sharpness", "sharpness" },
    -	{ "ExposureCompensation", "ev" },
     };
     int const argumentNamesSize =  sizeof(argumentNames) / sizeof(argumentNames[0]);
     
    @@ -215,33 +419,28 @@ ASI_CONTROL_CAPS ControlCapsArray[][MAX_NUM_CONTROL_CAPS] =
     	// Name, Description, MaxValue, MinValue, DefaultValue, CurrentValue, IsAutoSupported, IsWritable, ControlType
     	{ // imx477, libcamera		THIS MUST BE THE FIRST CAMERA
     		{ "Gain", "Gain", 16.0, 1.0, 1.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_GAIN },
    -		{ "Exposure", "Exposure Time (us)", 230 * US_IN_SEC, 1, 10000, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_EXPOSURE },
    +		{ "Exposure", "Exposure Time (us)", 230 * US_IN_SEC, 114, 10000, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_EXPOSURE },
     		{ "WB_R", "White balance: Red component", 10.0, 0.1, 2.5, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_R },
     		{ "WB_B", "White balance: Blue component", 10.0, 0.1, 2.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_B },
    -		{ "Temperature", "Sensor Temperature", 80, -20, NOT_SET, NOT_SET, ASI_FALSE, ASI_FALSE, ASI_TEMPERATURE },
     		{ "Flip", "Flip: 0->None, 1->Horiz, 2->Vert, 3->Both", 3, 0, 0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_FLIP },
     		{ "AutoExpMaxGain", "Auto exposure maximum gain value", 16.0, 1.0, 16.0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_GAIN },
     		{ "AutoExpMaxExpMS", "Auto exposure maximum exposure value (ms)", 230 * MS_IN_SEC, 1, 60 * MS_IN_SEC, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_EXP },
     		{ "ExposureCompensation", "Exposure Compensation", 10.0, -10.0, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, EV },
    -		// These are the same for all libcamera cameras.
    -		{ "Brightness", "Brightness", 1.0, -1.0, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_TARGET_BRIGHTNESS },
     		{ "Saturation", "Saturation", 15.99, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SATURATION },
     		{ "Contrast", "Contrast", 15.99, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, CONTRAST },
     		{ "Sharpness", "Sharpness", 15.99, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SHARPNESS },
     
     		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },	// Signals end of list
     	},
    -	{ // imx477, raspistill.  Minimum width and height are 64.
    +	{ // raspistill
     		{ "Gain", "Gain", 16.0, 1.0, 1.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_GAIN },
     		{ "Exposure", "Exposure Time (us)", 230 * US_IN_SEC, 1, 10000, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_EXPOSURE },
     		{ "WB_R", "White balance: Red component", 10.0, 0.1, 2.5, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_R },
     		{ "WB_B", "White balance: Blue component", 10.0, 0.1, 2.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_B },
    -		{ "Temperature", "Temperature, not supported", NOT_SET, NOT_SET, NOT_SET, NOT_SET, ASI_FALSE, ASI_FALSE, ASI_TEMPERATURE },
     		{ "Flip", "Flip: 0->None, 1->Horiz, 2->Vert, 3->Both", 3, 0, 0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_FLIP },
     		{ "AutoExpMaxGain", "Auto exposure maximum gain value", 16.0, 1.0, 16.0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_GAIN },
     		{ "AutoExpMaxExpMS", "Auto exposure maximum exposure value (ms)", 230 * MS_IN_SEC, 1, 60 * MS_IN_SEC, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_EXP },
     		{ "ExposureCompensation", "Exposure Compensation", 10, -10, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, EV },
    -		{ "Brightness", "Brightness", 100, 0, 50, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_TARGET_BRIGHTNESS },
     		{ "Saturation", "Saturation", 100, -100, 0, NOT_SET, ASI_FALSE, ASI_TRUE, SATURATION },
     		{ "Contrast", "Contrast", 100, -100, 0, NOT_SET, ASI_FALSE, ASI_TRUE, CONTRAST },
     		{ "Sharpness", "Sharpness", 100, -100, 0, NOT_SET, ASI_FALSE, ASI_TRUE, SHARPNESS },
    @@ -254,19 +453,17 @@ ASI_CONTROL_CAPS ControlCapsArray[][MAX_NUM_CONTROL_CAPS] =
     		{ "Exposure", "Exposure Time (us)", 112015553, 26, 10000, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_EXPOSURE },
     		{ "WB_R", "White balance: Red component", 32.0, 0.0, 2.5, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_R },
     		{ "WB_B", "White balance: Blue component", 32.0, 0.0, 2.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_B },
    -		{ "Temperature", "Sensor Temperature", 80, -20, NOT_SET, NOT_SET, ASI_FALSE, ASI_FALSE, ASI_TEMPERATURE },
     		{ "Flip", "Flip: 0->None, 1->Horiz, 2->Vert, 3->Both", 3, 0, 0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_FLIP },
     		{ "AutoExpMaxGain", "Auto exposure maximum gain value", 16.0, 1.122807, 16.0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_GAIN },
     		{ "AutoExpMaxExpMS", "Auto exposure maximum exposure value (ms)", 112015553 / US_IN_MS, 26.0, 60 * MS_IN_SEC, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_EXP },
     		{ "ExposureCompensation", "Exposure Compensation", 8.0, -8.0, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, EV },
    -		{ "Brightness", "Brightness", 1.0, -1.0, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_TARGET_BRIGHTNESS },
     		{ "Saturation", "Saturation", 32.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SATURATION },
     		{ "Contrast", "Contrast", 32.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, CONTRAST },
     		{ "Sharpness", "Sharpness", 32.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SHARPNESS },
     
     		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },
     	},
    -	{ // imx708*, raspistill.  Not supported.
    +	{ // raspistill.  Not supported.
     		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },
     	},
     
    @@ -275,19 +472,17 @@ ASI_CONTROL_CAPS ControlCapsArray[][MAX_NUM_CONTROL_CAPS] =
     		{ "Exposure", "Exposure Time (us)", 969249, 130, 9000, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_EXPOSURE },
     		{ "WB_R", "White balance: Red component", 32.0, 0.0, 0.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_R },
     		{ "WB_B", "White balance: Blue component", 32.0, 0.0, 0.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_B },
    -		{ "Temperature", "Temperature, not supported", NOT_SET, NOT_SET, NOT_SET, NOT_SET, ASI_FALSE, ASI_FALSE, ASI_TEMPERATURE },
     		{ "Flip", "Flip: 0->None, 1->Horiz, 2->Vert, 3->Both", 3, 0, 0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_FLIP },
     		{ "AutoExpMaxGain", "Auto exposure maximum gain value", 63.9375, 1.0, 63.9375, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_GAIN },
     		{ "AutoExpMaxExpMS", "Auto exposure maximum exposure value (ms)", 969249 / US_IN_MS, 1.0, 9 * MS_IN_SEC, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_EXP },
     		{ "ExposureCompensation", "Exposure Compensation", 8.0, -8.0, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, EV },
    -		{ "Brightness", "Brightness", 1.0, -1.0, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_TARGET_BRIGHTNESS },
     		{ "Saturation", "Saturation", 32, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SATURATION },
     		{ "Contrast", "Contrast", 32.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, CONTRAST },
     		{ "Sharpness", "Sharpness", 16.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SHARPNESS },
     
     		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },
     	},
    -	{ // ov5647, raspistill.  Not supported.
    +	{ // raspistill.  Not supported.
     		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },
     	},
     
    @@ -296,40 +491,36 @@ ASI_CONTROL_CAPS ControlCapsArray[][MAX_NUM_CONTROL_CAPS] =
     		{ "Exposure", "Exposure Time (us)", 200 * US_IN_SEC, 1, 10000, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_EXPOSURE },
     		{ "WB_R", "White balance: Red component", 10.0, 0.1, 2.5, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_R },
     		{ "WB_B", "White balance: Blue component", 10.0, 0.1, 2.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_B },
    -		{ "Temperature", "Sensor Temperature", 80, -20, NOT_SET, NOT_SET, ASI_FALSE, ASI_FALSE, ASI_TEMPERATURE },
     		{ "Flip", "Flip: 0->None, 1->Horiz, 2->Vert, 3->Both", 3, 0, 0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_FLIP },
     		{ "AutoExpMaxGain", "Auto exposure maximum gain value", 16.0, 1.0, 16.0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_GAIN },
     		{ "AutoExpMaxExpMS", "Auto exposure maximum exposure value (ms)", 200 * MS_IN_SEC, 1, 60 * MS_IN_SEC, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_EXP },
     		{ "ExposureCompensation", "Exposure Compensation", 10.0, -10.0, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, EV },
    -		{ "Brightness", "Brightness", 1.0, -1.0, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_TARGET_BRIGHTNESS },
     		{ "Saturation", "Saturation", 15.99, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SATURATION },
     		{ "Contrast", "Contrast", 15.99, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, CONTRAST },
     		{ "Sharpness", "Sharpness", 15.99, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SHARPNESS },
     
     		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },
     	},
    -	{ // imx290, raspistill.  Not supported.
    +	{ // raspistill.  Not supported.
     		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },
     	},
     
     	{ // imx519, libcamera
     		{ "Gain", "Gain", 16.0, 1.0, 1.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_GAIN },
     		{ "Exposure", "Exposure Time (us)", 200 * US_IN_SEC, 1, 10000, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_EXPOSURE },
    -		{ "WB_R", "White balance: Red component", 10.0, 0.1, 2.5, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_R },
    -		{ "WB_B", "White balance: Blue component", 10.0, 0.1, 2.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_B },
    -		{ "Temperature", "Sensor Temperature", 80, -20, NOT_SET, NOT_SET, ASI_FALSE, ASI_FALSE, ASI_TEMPERATURE },
    +		{ "WB_R", "White balance: Red component", 32.0, 0.1, 2.5, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_R },
    +		{ "WB_B", "White balance: Blue component", 32.0, 0.1, 2.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_B },
     		{ "Flip", "Flip: 0->None, 1->Horiz, 2->Vert, 3->Both", 3, 0, 0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_FLIP },
     		{ "AutoExpMaxGain", "Auto exposure maximum gain value", 16.0, 1.0, 16.0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_GAIN },
     		{ "AutoExpMaxExpMS", "Auto exposure maximum exposure value (ms)", 200 * MS_IN_SEC, 1, 60 * MS_IN_SEC, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_EXP },
     		{ "ExposureCompensation", "Exposure Compensation", 10.0, -10.0, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, EV },
    -		{ "Brightness", "Brightness", 1.0, -1.0, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_TARGET_BRIGHTNESS },
    -		{ "Saturation", "Saturation", 15.99, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SATURATION },
    -		{ "Contrast", "Contrast", 15.99, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, CONTRAST },
    -		{ "Sharpness", "Sharpness", 15.99, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SHARPNESS },
    +		{ "Saturation", "Saturation", 32.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SATURATION },
    +		{ "Contrast", "Contrast", 32.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, CONTRAST },
    +		{ "Sharpness", "Sharpness", 16.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SHARPNESS },
     
     		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },
     	},
    -	{ // imx519, raspistill.  Not supported.
    +	{ // raspistill.  Not supported.
     		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },
     	},
     
    @@ -339,19 +530,17 @@ ASI_CONTROL_CAPS ControlCapsArray[][MAX_NUM_CONTROL_CAPS] =
     		{ "Exposure", "Exposure Time (us)", 200 * US_IN_SEC, 1, 10000, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_EXPOSURE },
     		{ "WB_R", "White balance: Red component", 10.0, 0.1, 2.5, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_R },
     		{ "WB_B", "White balance: Blue component", 10.0, 0.1, 2.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_B },
    -		{ "Temperature", "Sensor Temperature", 80, -20, NOT_SET, NOT_SET, ASI_FALSE, ASI_FALSE, ASI_TEMPERATURE },
     		{ "Flip", "Flip: 0->None, 1->Horiz, 2->Vert, 3->Both", 3, 0, 0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_FLIP },
     		{ "AutoExpMaxGain", "Auto exposure maximum gain value", 16.0, 1.0, 16.0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_GAIN },
     		{ "AutoExpMaxExpMS", "Auto exposure maximum exposure value (ms)", 200 * MS_IN_SEC, 1, 60 * MS_IN_SEC, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_EXP },
     		{ "ExposureCompensation", "Exposure Compensation", 10.0, -10.0, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, EV },
    -		{ "Brightness", "Brightness", 1.0, -1.0, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_TARGET_BRIGHTNESS },
     		{ "Saturation", "Saturation", 15.99, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SATURATION },
     		{ "Contrast", "Contrast", 15.99, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, CONTRAST },
     		{ "Sharpness", "Sharpness", 15.99, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SHARPNESS },
     
     		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },
     	},
    -	{ // arducam_64mp, raspistill.  Not supported.
    +	{ // raspistill.  Not supported.
     		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },
     	},
     
    @@ -365,6 +554,26 @@ ASI_CONTROL_CAPS ControlCapsArray[][MAX_NUM_CONTROL_CAPS] =
     		{ "AutoExpMaxGain", "Auto exposure maximum gain value", 200.0, 1.0, 200.0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_GAIN },
     		{ "AutoExpMaxExpMS", "Auto exposure maximum exposure value (ms)", 15.5 * MS_IN_SEC, 1, 15.5 * MS_IN_SEC, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_EXP },
     		{ "ExposureCompensation", "Exposure Compensation", 8.0, -8.0, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, EV },
    +		{ "Saturation", "Saturation", 32.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SATURATION },
    +		{ "Contrast", "Contrast", 32.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, CONTRAST },
    +		{ "Sharpness", "Sharpness", 16.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SHARPNESS },
    +
    +		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },
    +	},
    +	{ // raspistill.  Not supported.
    +		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },
    +	},
    +
    +
    +	{ // Waveshare imx219, libcamera
    +		{ "Gain", "Gain", 10.666667, 1.0, 1.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_GAIN },
    +		{ "Exposure", "Exposure Time (us)", 11.767556 * US_IN_SEC, 75, 10 * US_IN_SEC, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_EXPOSURE },
    +		{ "WB_R", "White balance: Red component", 32.0, 0.0, 1.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_R },
    +		{ "WB_B", "White balance: Blue component", 32.0, 0.0, 1.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_B },
    +		{ "Flip", "Flip: 0->None, 1->Horiz, 2->Vert, 3->Both", 3, 0, 0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_FLIP },
    +		{ "AutoExpMaxGain", "Auto exposure maximum gain value", 10.666667, 1.0, 10.666667, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_GAIN },
    +		{ "AutoExpMaxExpMS", "Auto exposure maximum exposure value (ms)", 11.767556 * MS_IN_SEC, 1, 11.767556 * MS_IN_SEC, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_EXP },
    +		{ "ExposureCompensation", "Exposure Compensation", 8.0, -8.0, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, EV },
     		{ "Brightness", "Brightness", 1.0, -1.0, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_TARGET_BRIGHTNESS },
     		{ "Saturation", "Saturation", 32.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SATURATION },
     		{ "Contrast", "Contrast", 32.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, CONTRAST },
    @@ -372,50 +581,56 @@ ASI_CONTROL_CAPS ControlCapsArray[][MAX_NUM_CONTROL_CAPS] =
     
     		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },
     	},
    -	{ // arducam-pivariety, raspistill.  Not supported.
    +	{ // raspistill.  Not supported.
    +		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },
    +	},
    +
    +
    +	{	// Arducam ov64a40, libcamera
    +		{ "Gain", "Gain", 15.992188, 1.0, 1.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_GAIN },
    +		{ "Exposure", "Exposure Time (us)", 608453664, 580, 10000000, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_EXPOSURE },
    +		{ "WB_R", "White balance: Red component", 32.0, 0.0, 1.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_R },
    +		{ "WB_B", "White balance: Blue component", 32.0, 0.0, 1.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_B },
    +		{ "Flip", "Flip: 0->None 1->Horiz 2->Vert 3->Both", 3, 0, 0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_FLIP },
    +		{ "AutoExpMaxGain", "Auto exposure maximum gain value", 15.992188, 1.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_GAIN },
    +		{ "AutoExpMaxExpMS", "Auto exposure maximum exposure value (ms)", 608454, 0.580, 60000, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_EXP },
    +		{ "ExposureCompensation", "Exposure Compensation", 8.0, -8.0, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, EV },
    +		{ "Saturation", "Saturation", 32.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SATURATION },
    +		{ "Contrast", "Contrast", 32.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, CONTRAST },
    +		{ "Sharpness", "Sharpness", 16.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SHARPNESS },
    +
    +		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },
    +	},
    +	{ // raspistill.  Not supported.
    +		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },
    +	},
    +
    +
    +	{ // OneInchEye IMX283, libcamera
    +		{ "Gain", "Gain", 22.505495, 1.0, 4.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_GAIN },
    +		{ "Exposure", "Exposure Time (us)", 129373756, 58, 10 * US_IN_SEC, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_EXPOSURE },
    +		{ "WB_R", "White balance: Red component", 32.0, 0.0, 1.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_R },
    +		{ "WB_B", "White balance: Blue component", 32.0, 0.0, 1.0, NOT_SET, ASI_TRUE, ASI_TRUE, ASI_WB_B },
    +		{ "Flip", "Flip: 0->None 1->Horiz 2->Vert 3->Both", 3, 0, 0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_FLIP },
    +		{ "AutoExpMaxGain", "Auto exposure maximum gain value", 22.505495, 1.0, 4.0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_GAIN },
    +		{ "AutoExpMaxExpMS", "Auto exposure maximum exposure value (ms)", 129374, .0580, 60 * MS_IN_SEC, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_MAX_EXP },
    +		{ "ExposureCompensation", "Exposure Compensation", 8.0, -8.0, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, EV },
    +		{ "Brightness", "Brightness", 1.0, -1.0, 0.0, NOT_SET, ASI_FALSE, ASI_TRUE, ASI_AUTO_TARGET_BRIGHTNESS },
    +		{ "Saturation", "Saturation", 32.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SATURATION },
    +		{ "Contrast", "Contrast", 32.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, CONTRAST },
    +		{ "Sharpness", "Sharpness", 16.0, 0.0, 1.0, NOT_SET, ASI_FALSE, ASI_TRUE, SHARPNESS },
    +
    +		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },
    +	},
    +	{ // OneInchEye IMX283, raspistill.  Not supported.
     		{ "End", "End", 0.0, 0.0, 0.0, 0.0, ASI_FALSE, ASI_FALSE, CONTROL_TYPE_END },
     	},
     
     	// FUTURE CAMERAS GO HERE...
     };
     
    -char camerasInfoFile[128]	= { 0 };	// name of temporary file
     
     
    -// Return the number of cameras PHYSICALLY connected and put basic info on each camera in a file.
    -// We need the temporary file because it's a quick and dirty way to get output from system().
    -// TODO: use std::string exec(cmd) from allsky_common.cpp to avoid a temporary file
    -int ASIGetNumOfConnectedCameras()
    -{
    -	// CG.saveDir should be specified, but in case it isn't...
    -	const char *dir = CG.saveDir;
    -	if (dir == NULL)
    -		dir = "/tmp";
    -
    -	// File to hold info on all the cameras.
    -	snprintf(camerasInfoFile, sizeof(camerasInfoFile), "%s/%s_cameras.txt", dir, CAMERA_TYPE);
    -
    -	char cmd[300];
    -	int num;
    -	// Put the list of cameras and attributes in a file and return the number of cameras (the exit code).
    -	if (CG.isLibcamera)
    -	{
    -		// --list-cameras" writes to stderr.
    -		snprintf(cmd, sizeof(cmd), "NUM=$(LIBCAMERA_LOG_LEVELS=FATAL %s --list-cameras 2>&1 | grep -E '^[0-9 ]' | tee '%s' | grep -E '^[0-9] : ' | wc -l); exit ${NUM}", CG.cmdToUse, camerasInfoFile);
    -	}
    -	else
    -	{
    -		// raspistill doesn't return any info on cameras, so assume only 1 camera attached.
    -		// Further raspistill only supports RPi HQ camera which we assume to be 1st camera.
    -		snprintf(cmd, sizeof(cmd), "echo '0 : imx477 [%ldx%ld]' > '%s'; exit 1", ASICameraInfoArray[0].MaxWidth, ASICameraInfoArray[0].MaxHeight, camerasInfoFile);
    -	}
    -	num = system(cmd);
    -	if (WIFEXITED(num))
    -		num = WEXITSTATUS(num);
    -	Log(4, "cmd='%s', num=%d\n", cmd, num);
    -	return(num);
    -}
    -
     /* Sample "libcamera-still --list-cameras" ouput:
     	1 : imx477 [4056x3040] (/base/soc/i2c0mux/i2c@1/pca@70/i2c@1/imx477@1a)
         	Modes:	'SRGGB10_CSI2P' : 1332x990
    @@ -428,80 +643,318 @@ int ASIGetNumOfConnectedCameras()
     	Some cameras also have additional resolutions for a given mode.
     */
     
    -
     // Get the cameraNumber for the camera we're using.
    +// Also save the info on each connected camera of the current type.
     int getCameraNumber()
     {
    -	// Determine which camera sensor(s) we have by reading the file created in ASIGetNumOfConnectedCameras().
    -	if (camerasInfoFile[0] == '\0')
    +	int actualIndex;					// index into ASICameraInfoArray[]
    +	int RPiCameraIndex = -1;			// index into RPiCameras[]
    +	int thisIndex = -1;					// index of camera found in RPiCameras
    +	int num_RPiCameras = 0;
    +
    +	enum LINE_TYPE {
    +		LT_camera, LT_libcamera, LT_raspistill
    +	} lineType;
    +
    +if (0) {
    +	ASI_CAMERA_INFO *aci = NULL;
    +	size_t size = sizeof(ASI_CAMERA_INFO) * CG.numCameras;
    +	if ((aci = (ASI_CAMERA_INFO *) realloc(aci, size)) == NULL)
     	{
    -		Log(0, "%s: ERROR: camerasInfoFile not created!\n", CG.ME);
    +		int e = errno;
    +		Log(0, "*** %s: ERROR: Could not realloc() for aci: %s!", CG.ME, strerror(e));
     		closeUp(EXIT_ERROR_STOP);
     	}
    -	FILE *f = fopen(camerasInfoFile, "r");
    -	if (f == NULL)
    +
    +	const int cameraTokens = 15, controlCapsTokens = 9;
    +	const int maxArgs = cameraTokens;
    +	char *args[maxArgs] = {};		// maximum number of arguments
    +
    +	// Read the whole configuration file into memory so we can create argv with pointers.
    +	static char *buf = readFileIntoBuffer(&CG, CG.RPI_cameraInfoFile);
    +	if (buf == NULL)
     	{
    -		Log(0, "%s: ERROR: Unable to open '%s': %s\n", CG.ME, camerasInfoFile, strerror(errno));
    +		Log(0, "%s: ERROR: Unable to read from CG.RPI_cameraInfoFile '%s': %s\n",
    +			CG.ME, CG.RPI_cameraInfoFile, strerror(errno));
     		closeUp(EXIT_ERROR_STOP);
     	}
     
    -	char line[256];
    -	int num = NOT_SET;
    -#define SENSOR_STRING_SIZE	25
    -	char sensor[SENSOR_STRING_SIZE];
    -	int actualIndex;					// index into ASICameraInfoArray[]
    -	int RPiCameraIndex = -1;			// index into RPiCameras[]
    +	bool cameraMatch = false;
    +	bool inCamera = false, inControlCaps = false, inLibcamera = false;
    +	char full_line[1000];
    +	char *line;
    +	int on_line = 0;
    +	char *token;
    +	int max_tokens = 0;
    +
    +	(void) getLine(NULL);		// resets the buffer pointer
    +	while ((line = getLine(buf)) != NULL)
    +	{
    +		strcpy(full_line, line);		// use full_line in error messages
    +		on_line++;
    +		Log(5, "Line %3d: %s\n", on_line, full_line);
    +
    +		(void) getToken(NULL, '\t');		// tell getToken() we have a new line.
    +
    +		char *cameraLength = NULL;
    +		char *lt = getToken(line, '\t');	// line type
    +		if (lt == NULL)
    +		{
    +			Log(0, "%s: ERROR: Line %d: no line type found in %s [%s]\n",
    +				CG.ME, on_line, CG.RPI_cameraInfoFile, full_line);
    +			continue;
    +		}
    +
    +		if (strcmp(lt, "camera") == 0)
    +		{
    +			lineType = LT_camera;
    +			max_tokens = cameraTokens;
    +		}
    +		else if (strcmp(lt, "libcamera") == 0)
    +		{
    +			// Ignore lines not for this command.
    +			if (! CG.isLibcamera)
    +				continue;
    +
    +			lineType = LT_libcamera;
    +			max_tokens = controlCapsTokens;
    +		}
    +		else if (strcmp(lt, "raspistill") == 0)
    +		{
    +			// Ignore lines not for this command.
    +			if (CG.isLibcamera)
    +				continue;
    +
    +			lineType = LT_raspistill;
    +			max_tokens = controlCapsTokens;
    +		}
    +		else
    +		{
    +			Log(0, "%s: ERROR: Line %d: unknown line '%s' type in %s [%s]\n",
    +				CG.ME, on_line, lt, CG.RPI_cameraInfoFile, full_line);
    +			continue;
    +		}
    +
    +		// Create an array of arguments.
    +		int numTokens = 0;
    +		while ((token = getToken(line, '\t')) != NULL)
    +		{
    +			numTokens++;
    +// printf("xxxxx token %d: %s\n", numTokens, token);
    +
    +			if (numTokens == 1)
    +			{
    +				if (strcmp(token, "End") == 0)
    +				{
    +					break;
    +				}
    +
    +				if (lineType == LT_camera)
    +				{
    +					// See if this is one of the connected cameras.
    +
    +					// Determine how much of the Sensor name to compare.
    +					cameraLength = getToken(line, '\t');
    +// TODO: check for NULL
    +// printf("xxxxx >> token (l) %d: %s\n", numTokens+1, cameraLength);
    +					size_t len = atoi(cameraLength);
    +					if (len == 0)
    +					{
    +						len = strlen(token);
    +					}
    +
    +					cameraMatch = false;
    +					for (int cc=0; cc < totalNum_connectedCameras; cc++)
    +					{
    +						CONNECTED_CAMERAS *cC = &connectedCameras[cc];
    +						if (strcmp(cC->Type, CAMERA_TYPE) != 0)
    +						{
    +							continue;
    +						}
    +
    +// printf(">>>> checking %s vs %s for %ld [%s]\n", token, cC->Sensor, len, cameraLength);
    +			
    +						// Now compare the attached sensor name with what's in our list.
    +						if (strncmp(token, cC->Sensor, len) == 0)
    +						{
    +							cameraMatch = true;
    +							num_RPiCameras++;
    +							Log(5, "[[[[[[[[[[[[[ MATCH, num_RPiCameras=%d\n", num_RPiCameras);
    +							break;
    +						}
    +					}
    +				}
    +
    +				if (cameraMatch)
    +				{
    +					if (lineType == LT_camera)
    +					{
    +						inCamera = true;
    +						inLibcamera = false;
    +						inControlCaps = false;
    +					}
    +					else if (lineType == LT_libcamera)
    +					{
    +						inLibcamera = true;
    +						inControlCaps = true;
    +					}
    +					else if (lineType == LT_raspistill)
    +					{
    +						inLibcamera = false;
    +						inControlCaps = true;
    +					}
    +				}
    +			}
    +			else if (numTokens > max_tokens)
    +			{
    +				Log(5, "Too many tokens (%d vs %d)\n", numTokens, max_tokens);
    +				break;
    +			}
    +
    +			if (cameraMatch)
    +			{
    +				Log(5, "SETTING args[%d] to %s\n", numTokens-1, token);
    +				args[numTokens-1] = token;
    +				if (lineType == LT_camera && numTokens == 1)
    +				{
    +					numTokens++;
    +					Log(5, ">> SETTING args[%d] to %s\n", numTokens-1, cameraLength);
    +					args[numTokens-1] = cameraLength;
    +				}
    +			}
    +		}
    +
    +		if (cameraMatch)
    +		{
    +			if (numTokens == 1)
    +			{
    +				// End of control capability entries for this camera.
    +				inCamera = false;
    +				inLibcamera = false;
    +				inControlCaps = false;
    +			}
    +			else if (numTokens == cameraTokens)
    +			{
    +				// camera entry
    +				strncpy(aci[num_RPiCameras - 1].Module, args[0], MODULE_SIZE-1);
    +				aci[num_RPiCameras - 1].Module_len = atol(args[1]);
    +// TODO: add rest of args.
    +
    +				inCamera = true;
    +			}
    +			else if (numTokens == controlCapsTokens)
    +			{
    +				// control caps entry
    +// TODO: add to CC array.
    +				inControlCaps = true;
    +			}
    +			else
    +			{
    +				Log(-1, "%s: Ignoring line %d in %s: too many %s tokens (%d) [%s]\n",
    +					CG.ME, on_line, CG.RPI_cameraInfoFile,
    +					(lineType == LT_camera) ? "camera" : "control caps",
    +					numTokens, full_line);
    +			}
    +		}
    +if (numTokens > 1) Log(5, ", inCamera=%s, inControlCaps=%s, inLibcamera=%s\n", yesNo(inCamera), yesNo(inControlCaps), yesNo(inLibcamera));
    +
    +		if (num_RPiCameras == CG.numCameras)
    +		{
    +			Log(5, "Found %d cameras; skipping rest of file\n", CG.numCameras);
    +			break;
    +		}
    +
    +	}
    +}// end of if(0)
     
     	// For each camera found, update the next *RPiCameras[] entry to point to the
     	// camera's ASICameraInfoArray[] entry.
     	// Return the index into *RPiCameras[] of the attached camera we're using.
    -	while (fgets(line, sizeof(line)-1, f) != NULL)
    +
    +	for (int cc=0; cc < totalNum_connectedCameras; cc++)
     	{
    -		// Sample line:     0 : imx477 [4056x3040] ....
    -		// We only care about first two.
    -		if (sscanf(line, "%d : %s ", &num, sensor) == 2)
    +		CONNECTED_CAMERAS *cC = &connectedCameras[cc];
    +		if (strcmp(cC->Type, CAMERA_TYPE) != 0)
     		{
    -			// Found a camera; check all known cameras to make sure it's one we know about.
    -			// Unfortunately we don't have anything else to check, like serial number.
    -			// I suppose we could also check the Modes are the same, but it's not worth it.
    -			bool foundThisSensor = false;
    -			for (int i=0; i < ASICameraInfoArraySize; i++)
    +			continue;
    +		}
    +
    +		char *sensor = cC->Sensor;
    +
    +		// Found a camera of the right type.
    +
    +// XXX TODO: use RPI_cameraInfoFile instead
    +		// Check all known cameras to make sure it's one we know about.
    +		for (int i=0; i < ASICameraInfoArraySize; i++)
    +		{
    +			ASI_CAMERA_INFO *p = &ASICameraInfoArray[i];
    +
    +			// This code tells us how much of the Module name to compare.
    +			size_t len;
    +			if (p->Module_len > 0)
    +				len = p->Module_len;
    +			else
    +				len = sizeof(p->Module);
    +
    +			// Now compare the attached sensor name with what's in our list.
    +			if (strncmp(sensor, p->Module, len) == 0)
     			{
    -				// This code tells us how much of the Module name to compare.
    -				size_t len;
    -				if (ASICameraInfoArray[i].Module_len > 0)
    -					len = ASICameraInfoArray[i].Module_len;
    +				// The sensor is in our list.
    +				actualIndex = i;
    +				num_RPiCameras++;
    +				thisIndex++;
    +				cC->Module_len = p->Module_len;
    +				cC->Module = p->Module;
    +
    +				strncpy(p->Sensor, sensor, SENSOR_SIZE);
    +				RPiCameras[thisIndex].CameraInfo = &ASICameraInfoArray[actualIndex];
    +				// There are TWO entries in ControlCapsArray[] for every
    +				// entry in ASICameraInfoArray[].
    +				// The first of each pair is for libcamera, the second is for raspistill.
    +				// We need to return the index into ControlCapsArray[].
    +				Log(4, "Saving sensor [%s] from ASICameraInfoArray[%d] to RPiCameras[%d],",
    +					sensor, actualIndex, thisIndex);
    +				actualIndex = (actualIndex * 2) + (CG.isLibcamera ? 0 : 1);
    +				RPiCameras[thisIndex].ControlCaps = &ControlCapsArray[actualIndex][0];
    +				Log(4, " ControlCapsArray[%d]", actualIndex);
    +
    +				// Use camera model if we have it.
    +				if (CG.cm[0] != '\0')
    +				{
    + 					if (strcmp(RPiCameras[thisIndex].CameraInfo->Name, CG.cm) == 0)
    +					{
    +						RPiCameraIndex = thisIndex;
    +						Log(4, " - MATCH on cm=%s\n", CG.cm);
    +					}
    +					else
    +					{
    +						Log(4, ".\n");
    +					}
    +				} else if (thisIndex == CG.cameraNumber)
    +				{
    +					RPiCameraIndex = thisIndex;
    +					Log(4, " - MATCH\n");
    +				}
     				else
    -					len = sizeof(ASICameraInfoArray[i].Module);
    -
    -				// Now compare the attached sensor name with what's in our list.
    -				if (strncmp(sensor, ASICameraInfoArray[i].Module, len) == 0)
     				{
    -					// The sensor is in our list.
    -					foundThisSensor = true;
    -					actualIndex = i;
    -					RPiCameraIndex++;
    -
    -					RPiCameras[RPiCameraIndex].CameraInfo = &ASICameraInfoArray[actualIndex];
    -					// There are TWO entries in ControlCapsArray[] for every entry in ASICameraInfoArray[].
    -					// The first of each pair is for libcamera, the second is for raspistill.
    -					// We need to return the index into ControlCapsArray[].
    -					Log(4, "Camera matched ASICameraInfoArray[%d] (RPiCameras[%d]): sensor %s,", actualIndex, RPiCameraIndex, sensor);
    -					actualIndex = (actualIndex * 2) + (CG.isLibcamera ? 0 : 1);
    -					RPiCameras[RPiCameraIndex].ControlCaps = &ControlCapsArray[actualIndex][0];
    -					Log(4, " ControlCapsArray[%d].\n", actualIndex);
    -
    -					break;
    +					Log(4, ".\n");
     				}
    -			}
    -			if (! foundThisSensor) {
    -				Log(1, "%s: WARNING: Sensor '%s' found but not supported by Allsky.\n", CG.ME, sensor);
    +
    +				break;		// exit inner loop
     			}
     		}
     	}
    +
    +	// These checks should "never" fail since allsky.sh created the input file
    +	// based on what's connected.
    +	if (num_RPiCameras == 0)
    +	{
    +		Log(0, "%s: ERROR: No %s cameras found.\n", CG.ME, CAMERA_TYPE);
    +		closeUp(EXIT_NO_CAMERA);
    +	}
     	if (RPiCameraIndex == -1)
     	{
    -		Log(0, "%s: ERROR: No RPi cameras found.\n", CG.ME);
    +		Log(0, "%s: ERROR: camera number %d not found.\n", CG.ME, CG.cameraNumber);
     		closeUp(EXIT_NO_CAMERA);
     	}
     
    @@ -514,7 +967,8 @@ ASI_ERROR_CODE ASIGetCameraProperty(ASI_CAMERA_INFO *pASICameraInfo, int iCamera
     {
     	if (iCameraIndex < 0 || iCameraIndex >= CG.numCameras)
     	{
    -		Log(0, "%s: ERROR: ASIGetCameraProperty(), iCameraIndex (%d) bad.\n", CG.ME, iCameraIndex);
    +		Log(0, "%s: ERROR: ASIGetCameraProperty(), iCameraIndex (%d) bad (CG.numCameras=%d).\n",
    +			CG.ME, iCameraIndex, CG.numCameras);
     		return(ASI_ERROR_INVALID_INDEX);
     	}
     
    @@ -548,7 +1002,10 @@ ASI_ERROR_CODE ASIGetNumOfControls(int iCameraIndex, int *piNumberOfControls)
     
     // Get the camera control at index iControlIndex in the array, and put in pControlCaps.
     // This is typically used in a loop over all the control capabilities.
    -ASI_ERROR_CODE ASIGetControlCaps(int iCameraIndex, int iControlIndex, ASI_CONTROL_CAPS *pControlCaps)
    +ASI_ERROR_CODE ASIGetControlCaps(
    +		int iCameraIndex,
    +		int iControlIndex,
    +		ASI_CONTROL_CAPS *pControlCaps)
     {
     	if (iCameraIndex < 0 || iCameraIndex >= CG.numCameras)
     		return(ASI_ERROR_INVALID_INDEX);
    @@ -563,7 +1020,11 @@ ASI_ERROR_CODE ASIGetControlCaps(int iCameraIndex, int iControlIndex, ASI_CONTRO
     
     
     // Get the specified control capability's data value and put in plValue.
    -ASI_ERROR_CODE ASIGetControlValue(int iCameraIndex, ASI_CONTROL_TYPE ControlType, double *plValue, ASI_BOOL *pbAuto)
    +ASI_ERROR_CODE ASIGetControlValue(
    +		int iCameraIndex,
    +		ASI_CONTROL_TYPE ControlType,
    +		double *plValue,
    +		ASI_BOOL *pbAuto)
     {
     	if (iCameraIndex < 0 || iCameraIndex >= CG.numCameras)
     		return(ASI_ERROR_INVALID_INDEX);
    @@ -587,8 +1048,11 @@ ASI_ERROR_CODE ASIGetControlValue(int iCameraIndex, ASI_CONTROL_TYPE ControlType
     }
     
     
    -// Empty routine so code compiles.
    -int stopVideoCapture(int cameraID) { return(ASI_SUCCESS); }
    +// Empty routines so code compiles.
    +int stopExposure(int cameraID) { return((int) ASI_SUCCESS); }
    +int stopVideoCapture(int cameraID) { return((int) ASI_SUCCESS); }
    +int closeCamera(int cameraID) { return((int) ASI_SUCCESS); }
    +char const *getZWOexposureType(ZWOexposure t) { return("ZWOend"); }
     
     // Get the camera's serial number.  RPi cameras don't support serial numbers.
     ASI_ERROR_CODE  ASIGetSerialNumber(int iCameraIndex, ASI_SN *pSN)
    @@ -636,17 +1100,29 @@ char const *argumentNames[][2] = {
     	{ "AntiDewHeater", "" },		// correct Control name?
     	{ "FanAdjust", "" },
     	{ "PwrledBright", "" },
    +	{ "USBHubReset", "" },
     	{ "GPSSupport", "" },
     	{ "GPSStartLine", "" },
     	{ "GPSEndLine", "" },
     	{ "RollingInterval", "" },
    +	{ "future use 1", "" },		// in case ZWO adds more and we don't realize it
    +	{ "future use 2", "" },
    +	{ "future use 3", "" },
     };
     int const argumentNamesSize =  sizeof(argumentNames) / sizeof(argumentNames[0]);
     
    +int stopExposure(int cameraID)
    +{
    +	return((int) ASIStopExposure(cameraID));
    +}
     int stopVideoCapture(int cameraID)
     {
     	return((int) ASIStopVideoCapture(cameraID));
     }
    +int closeCamera(int cameraID)
    +{
    +	return((int) ASICloseCamera(cameraID));
    +}
     
     int getCameraNumber()
     {
    @@ -675,6 +1151,15 @@ ASI_ID getCameraID(ASI_CAMERA_INFO camInfo)
     
     	return(cameraID);
     }
    +
    +char const *getZWOexposureType(ZWOexposure t)
    +{
    +	if (t == ZWOsnap) return("snapshot");
    +	if (t == ZWOvideoOff) return("video off between frames");
    +	if (t == ZWOvideo) return("video (original)");
    +	return("invalid type");
    +}
    +
     #endif		// IS_RPi
     
     
    @@ -704,8 +1189,12 @@ char *getRetCode(ASI_ERROR_CODE code)
     	else if (code == ASI_ERROR_OUTOF_BOUNDARY) ret = "ASI_ERROR_OUTOF_BOUNDARY";
     	else if (code == ASI_ERROR_TIMEOUT)
     	{
    -		std::string yesno = CG.videoOffBetweenImages ? "YES" : "NO";
    -		ret = "ASI_ERROR_TIMEOUT (with 0.8 exposure = " + yesno + ")";
    +		ret = "ASI_ERROR_TIMEOUT";
    +		if (CG.ZWOexposureType == ZWOvideoOff)
    +			ret += " (video off between images)";
    +		else if (CG.ZWOexposureType == ZWOvideo)
    +			ret += " (original video mode)";
    +		// else just return ASI_ERROR_TIMEOUT.  Should never happen in ZWOsnap mode.
     	}
     	else if (code == ASI_ERROR_INVALID_SEQUENCE) ret = "ASI_ERROR_INVALID_SEQUENCE";
     	else if (code == ASI_ERROR_BUFFER_TOO_SMALL) ret = "ASI_ERROR_BUFFER_TOO_SMALL";
    @@ -724,7 +1213,8 @@ char *getRetCode(ASI_ERROR_CODE code)
     // Get the number of cameras PHYSICALLY connected, making sure there's at least one.
     void processConnectedCameras()
     {
    -	CG.numCameras = ASIGetNumOfConnectedCameras();
    +	// This also sets global totalNum_connectedCameras.
    +	CG.numCameras = getNumOfConnectedCameras();
     	if (CG.numCameras <= 0)
     	{
     		Log(0, "*** %s: ERROR: No Connected Camera...\n", CG.ME);
    @@ -738,21 +1228,56 @@ void processConnectedCameras()
     			CG.ME, CG.cameraNumber, CG.numCameras-1);
     		closeUp(EXIT_NO_CAMERA);
     	}
    -	else if (CG.numCameras > 1)
    -	{
    -		ASI_CAMERA_INFO info;
    +
    +	if (CG.numCameras > 1 && CG.debugLevel >= 4)
     		printf("\nAttached Cameras:\n");
    -		for (int i = 0; i < CG.numCameras; i++)
    +
    +	ASI_CAMERA_INFO info;
    +	int numThisType = 0;
    +	for (int cc=0; cc < totalNum_connectedCameras; cc++)
    +	{
    +		CONNECTED_CAMERAS *cC = &connectedCameras[cc];
    +		if (strcmp(cC->Type, CAMERA_TYPE) != 0)
     		{
    -			ASIGetCameraProperty(&info, i);
    -			printf("  - %d %s%s\n", i, info.Name, i == CG.cameraNumber ? " (selected)" : "");
    +			continue;
     		}
    +
    +		if (ASIGetCameraProperty(&info, numThisType) != ASI_SUCCESS)
    +		{
    +#ifdef IS_ZWO	// RPi version already displayed message.
    +			Log(0, "ERROR: can't get information for camera number %d.\n", numThisType);
    +#endif
    +			numThisType++;
    +			continue;
    +		}
    +
    +		if (CG.numCameras > 1 && CG.debugLevel >= 4)
    +			printf("  - %d", numThisType);
    +
    +		char *cm;
    +#ifdef IS_RPi
    +		strncpy(cC->Name, info.Name, CAMERA_NAME_SIZE);
    +		snprintf(cC->Sensor, FULL_SENSOR_SIZE, "%s [%s]", info.Name, info.Sensor);
    +		cm = cC->Sensor;
    +#else
    +		cm = info.Name;
    +#endif
    +		if (CG.numCameras > 1 && CG.debugLevel >= 4)
    +		{
    +			printf(" %s ", cm);
    +			printf(" %s\n", numThisType == CG.cameraNumber ? " (selected)" : "");
    +		}
    +
    +		numThisType++;
     	}
     }
     
     
     // Get the camera control with the specified control type.
    -ASI_ERROR_CODE getControlCapForControlType(int iCameraIndex, ASI_CONTROL_TYPE ControlType, ASI_CONTROL_CAPS *pControlCap)
    +ASI_ERROR_CODE getControlCapForControlType(
    +		int iCameraIndex,
    +		ASI_CONTROL_TYPE ControlType,
    +		ASI_CONTROL_CAPS *pControlCap)
     {
     	if (iCameraIndex < 0 || iCameraIndex >= CG.numCameras)
     		return(ASI_ERROR_INVALID_INDEX);
    @@ -816,51 +1341,121 @@ char *getSerialNumber(int camNum)
     	return(sn);
     }
     
    -// Get the camera model.
    -ASI_CAMERA_INFO x_;
    -size_t s_ = sizeof(x_.Name);		// is NULL-terminated
    -char cameraModel[sizeof(x_.Name) + 1];
    -
    -char *getCameraModel(ASI_CAMERA_INFO cameraInfo)
    +// Remove the camera type from the name if it's there.
    +char *skipType(char *cameraName)
     {
    -	// Remove the camera type from the name if it's there.
    -	char *p = cameraInfo.Name;
    -	if (strncmp(CAMERA_TYPE, p, strlen(CAMERA_TYPE)) == 0)
    -		p += strlen(CAMERA_TYPE);
    +	static char *p;
    +	p = cameraName;
    +	int l = strlen(CAMERA_TYPE);
    +
    +	if (strncmp(CAMERA_TYPE, p, l) == 0)
    +		p += l;
     	if (*p == ' ') p++;		// skip optional space
    -	strncpy(cameraModel, p, s_-1);
    -	for (unsigned int i=0; i<s_; i++)
    -	{
    -		// Don't want spaces in the file name - they are a hassle.
    -		if (cameraModel[i] == ' ')
    -			cameraModel[i] = '_';
    -	}
    +	return(p);
    +}
    +
    +
    +// Get the camera model, removing the camera type from the name if it's there.
    +char *getCameraModel(char *cameraName)
    +{
    +	static char cameraModel[CAMERA_NAME_SIZE + 1];
    +	strcpy(cameraModel, skipType(cameraName));
     
     	return(cameraModel);
     }
     
     // Save information on the specified camera.
    -void saveCameraInfo(ASI_CAMERA_INFO cameraInfo, char const *file, int width, int height, double pixelSize, char const *bayer)
    +void saveCameraInfo(
    +		ASI_CAMERA_INFO cameraInfo,
    +		char const *file,
    +		int width, int height,
    +		double pixelSize,
    +		char const *bayer)
     {
    -	char *camModel = getCameraModel(cameraInfo);
     	char *sn = getSerialNumber(cameraInfo.CameraID);
    +	char camModel[CAMERA_NAME_SIZE + 1];
    +	strncpy(camModel, getCameraModel(cameraInfo.Name), CAMERA_NAME_SIZE);
     
    -	FILE *f = fopen(file, "w");
    -	if (f == NULL)
    +	FILE *f;
    +	if (strcmp(file, "-") == 0)
     	{
    -		Log(0, "%s: ERROR: Unable to open '%s': %s\n", CG.ME, file, strerror(errno));
    -		closeUp(EXIT_ERROR_STOP);
    +		f = stdout;
    +		file = "stdout";
    +	}
    +	else
    +	{
    +		f = fopen(file, "w");
    +		if (f == NULL)
    +		{
    +			Log(0, "%s: ERROR: Unable to open '%s': %s\n", CG.ME, file, strerror(errno));
    +			closeUp(EXIT_ERROR_STOP);
    +		}
     	}
     	Log(4, "saveCameraInfo(): saving to %s\n", file);
     
     	// output basic information on camera as well as all it's capabilities
     	fprintf(f, "{\n");
     	fprintf(f, "\t\"cameraType\" : \"%s\",\n", CAMERA_TYPE);
    +		fprintf(f, "\t\"cameraTypes\" : [\n");
    +		for (int camType = 0; camType < num_connectedCameraTypes; camType++)
    +		{
    +			fprintf(f, "\t\t{ \"value\" : \"%s\", \"label\" : \"%s\" },\n",
    +				connectedCameraTypes[camType], connectedCameraTypes[camType]);
    +		}
    +		fprintf(f, "\t\t{ \"value\" : \"%s\", \"label\" : \"%s\" }\n", "Refresh", "Refresh");
    +		fprintf(f, "\t],\n");
     	fprintf(f, "\t\"cameraName\" : \"%s\",\n", cameraInfo.Name);
     	fprintf(f, "\t\"cameraModel\" : \"%s\",\n", camModel);
    +		fprintf(f, "\t\"cameraModels\" : [\n");
    +		int numThisType = 0;
    +		bool foundThisModel = false;
    +		for (int cc=0; cc < totalNum_connectedCameras; cc++)
    +		{
    +			CONNECTED_CAMERAS *cC = &connectedCameras[cc];
    +			if (strcmp(cC->Type, CAMERA_TYPE) != 0)
    +			{
    +				continue;
    +			}
    +
    +			if (numThisType > 0)
    +			{
    +				fprintf(f, ",");		// comma on all but last one
    +				fprintf(f, "\n");
    +			}
    +			char *cm = getCameraModel(cC->Name);
    +			Log(5, "cC->Name=%s, cm=%s, camModel=%s\n", cC->Name, cm, camModel);
    +			if (strcmp(cm, camModel) == 0)
    +			{
    +				foundThisModel = true;
    +			}
    +			fprintf(f, "\t\t{ \"value\" : \"%s\", \"label\" : \"%s\" }",
    +				cm,
    +#ifdef IS_RPi
    +				skipType(cC->Sensor)
    +#else
    +				cm
    +#endif
    +			);
    +
    +			numThisType++;
    +		}
    +		fprintf(f, "\n\t],\n");
    +		if (! foundThisModel)
    +		{
    +			Log(0, "%s: ERROR: Currently connected '%s %s' camera not found in '%s'.\n",
    +				CG.ME, CAMERA_TYPE, camModel, CG.connectedCamerasFile);
    +			if (f != stdout)
    +				fclose(f);
    +			closeUp(EXIT_ERROR_STOP);
    +		}
    +#ifdef IS_RPi
    +	fprintf(f, "\t\"sensor\" : \"%s\",\n", cameraInfo.Sensor);
    +#endif
     #ifdef IS_ZWO
     	fprintf(f, "\t\"cameraID\" : \"%s\",\n", hasCameraID ? (char const *)cID : "");
     #endif
    +	fprintf(f, "\t\"cameraNumber\" : %d,\n", CG.cameraNumber);
    +
     	fprintf(f, "\t\"serialNumber\" : \"%s\",\n", hasSerialNumber ? sn : "");
     	fprintf(f, "\t\"sensorWidth\" : %d,\n", width);
     	fprintf(f, "\t\"sensorHeight\" : %d,\n", height);
    @@ -871,7 +1466,6 @@ void saveCameraInfo(ASI_CAMERA_INFO cameraInfo, char const *file, int width, int
     			int b = cameraInfo.SupportedBins[i];
     			if (b == 0)
     			{
    -				fprintf(f, "\n");
     				break;
     			}
     			if (i > 0)
    @@ -881,8 +1475,11 @@ void saveCameraInfo(ASI_CAMERA_INFO cameraInfo, char const *file, int width, int
     			}
     			fprintf(f, "\t\t{ \"value\" : %d, \"label\" : \"%dx%d\" }", b, b, b);
     		}
    -	fprintf(f, "\t],\n");
    +	fprintf(f, "\n\t],\n");
     
    +	// RPi only supports sensor temp with libcamera.
    +	if (CG.ct == ctZWO || CG.isLibcamera)
    +		fprintf(f, "\t\"hasSensorTemperature\" : %s,\n", CG.supportsTemperature ? "true" : "false");
     	fprintf(f, "\t\"colorCamera\" : %s,\n", cameraInfo.IsColorCam ? "true" : "false");
     	if (cameraInfo.IsColorCam)
     		fprintf(f, "\t\"bayerPattern\" : \"%s\",\n", bayer);
    @@ -918,7 +1515,6 @@ void saveCameraInfo(ASI_CAMERA_INFO cameraInfo, char const *file, int width, int
     			ASI_IMG_TYPE it = cameraInfo.SupportedVideoFormat[i];
     			if (it == ASI_IMG_END)
     			{
    -				fprintf(f, "\n");
     				break;
     			}
     			if (i > 0)
    @@ -936,13 +1532,18 @@ void saveCameraInfo(ASI_CAMERA_INFO cameraInfo, char const *file, int width, int
     				"unknown format");
     			fprintf(f, " }");
     		}
    -	fprintf(f, "\t],\n");
    +	fprintf(f, "\n\t],\n");
    +
     
     	// Add some other things the camera supports, or the software supports for this camera.
     	// Adding it to the "controls" array makes the code that checks what's available easier.
     	fprintf(f, "\t\"controls\": [\n");
     
    -	// sensor size was also saved above, but save here with min/max/default
    +#ifdef IS_ZWO
    +	// Setting the sensor width and height with libcamera does a digital zoom,
    +	// then resizes the resulting image back to the original size.
    +
    +	// sensor size was also saved above, but this is the size the user can change.
     	fprintf(f, "\t\t{\n");
     	fprintf(f, "\t\t\t\"Name\" : \"sensorWidth\",\n");
     	fprintf(f, "\t\t\t\"argumentName\" : \"width\",\n");
    @@ -958,6 +1559,94 @@ void saveCameraInfo(ASI_CAMERA_INFO cameraInfo, char const *file, int width, int
     	fprintf(f, "\t\t\t\"MaxValue\" : %d,\n", height);
     	fprintf(f, "\t\t\t\"DefaultValue\" : 0\n");
     	fprintf(f, "\t\t},\n");
    +#endif
    +
    +	// Crop values
    +	float maxCropPercent = 0.9;	// Don't allow full size
    +	fprintf(f, "\t\t{\n");
    +	fprintf(f, "\t\t\t\"Name\" : \"imageCropTop\",\n");
    +	fprintf(f, "\t\t\t\"argumentName\" : \"imagecroptop\",\n");
    +	fprintf(f, "\t\t\t\"MinValue\" : 0,\n");
    +	fprintf(f, "\t\t\t\"MaxValue\" : %d,\n", int(height * maxCropPercent));
    +	fprintf(f, "\t\t\t\"DefaultValue\" : 0\n");
    +	fprintf(f, "\t\t},\n");
    +	fprintf(f, "\t\t{\n");
    +	fprintf(f, "\t\t\t\"Name\" : \"imageCropRight\",\n");
    +	fprintf(f, "\t\t\t\"argumentName\" : \"imagecropright\",\n");
    +	fprintf(f, "\t\t\t\"MinValue\" : 0,\n");
    +	fprintf(f, "\t\t\t\"MaxValue\" : %d,\n", int(width * maxCropPercent));
    +	fprintf(f, "\t\t\t\"DefaultValue\" : 0\n");
    +	fprintf(f, "\t\t},\n");
    +	fprintf(f, "\t\t{\n");
    +	fprintf(f, "\t\t\t\"Name\" : \"imageCropBottom\",\n");
    +	fprintf(f, "\t\t\t\"argumentName\" : \"imagecropbottom\",\n");
    +	fprintf(f, "\t\t\t\"MinValue\" : 0,\n");
    +	fprintf(f, "\t\t\t\"MaxValue\" : %d,\n", int(height * maxCropPercent));
    +	fprintf(f, "\t\t\t\"DefaultValue\" : 0\n");
    +	fprintf(f, "\t\t},\n");
    +	fprintf(f, "\t\t{\n");
    +	fprintf(f, "\t\t\t\"Name\" : \"imageCropLeft\",\n");
    +	fprintf(f, "\t\t\t\"argumentName\" : \"imagecropleft\",\n");
    +	fprintf(f, "\t\t\t\"MinValue\" : 0,\n");
    +	fprintf(f, "\t\t\t\"MaxValue\" : %d,\n", int(width * maxCropPercent));
    +	fprintf(f, "\t\t\t\"DefaultValue\" : 0\n");
    +	fprintf(f, "\t\t},\n");
    +
    +	// Max values for images, timelapse, ...
    +	fprintf(f, "\t\t{\n");
    +	fprintf(f, "\t\t\t\"Name\" : \"timelapseWidth\",\n");
    +	fprintf(f, "\t\t\t\"argumentName\" : \"timelapsewidth\",\n");
    +	fprintf(f, "\t\t\t\"MaxValue\" : %d\n", width);
    +	fprintf(f, "\t\t},\n");
    +	fprintf(f, "\t\t{\n");
    +	fprintf(f, "\t\t\t\"Name\" : \"timelapseHeight\",\n");
    +	fprintf(f, "\t\t\t\"argumentName\" : \"timelapseheight\",\n");
    +	fprintf(f, "\t\t\t\"MaxValue\" : %d\n", height);
    +	fprintf(f, "\t\t},\n");
    +	fprintf(f, "\t\t{\n");
    +	fprintf(f, "\t\t\t\"Name\" : \"minitimelapseWidth\",\n");
    +	fprintf(f, "\t\t\t\"argumentName\" : \"minitimelapsewidth\",\n");
    +	fprintf(f, "\t\t\t\"MaxValue\" : %d\n", width);
    +	fprintf(f, "\t\t},\n");
    +	fprintf(f, "\t\t{\n");
    +	fprintf(f, "\t\t\t\"Name\" : \"minitimelapseHeight\",\n");
    +	fprintf(f, "\t\t\t\"argumentName\" : \"minitimelapseheight\",\n");
    +	fprintf(f, "\t\t\t\"MaxValue\" : %d\n", height);
    +	fprintf(f, "\t\t},\n");
    +	fprintf(f, "\t\t{\n");
    +	fprintf(f, "\t\t\t\"Name\" : \"imageresizeuploadsWidth\",\n");
    +	fprintf(f, "\t\t\t\"argumentName\" : \"imageresizeuploadswidth\",\n");
    +	fprintf(f, "\t\t\t\"MaxValue\" : %d\n", width);
    +	fprintf(f, "\t\t},\n");
    +	fprintf(f, "\t\t{\n");
    +	fprintf(f, "\t\t\t\"Name\" : \"imageresizeuploadsHeight\",\n");
    +	fprintf(f, "\t\t\t\"argumentName\" : \"imageresizeuploadsheight\",\n");
    +	fprintf(f, "\t\t\t\"MaxValue\" : %d\n", height);
    +	fprintf(f, "\t\t},\n");
    +
    +	// Autogain - RPi should be on, ZWO off (until we implement the RPi autoexposure/gain algorithm on ZWO).
    +	fprintf(f, "\t\t{\n");
    +	fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "dayautogain");
    +	fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "dayautogain");
    +	fprintf(f, "\t\t\t\"DefaultValue\" : %s\n",
    +#ifdef IS_ZWO
    +		"false"
    +#else
    +		"true"
    +#endif
    +		);
    +	fprintf(f, "\t\t},\n");
    +	fprintf(f, "\t\t{\n");
    +	fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "nightautogain");
    +	fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "nightautogain");
    +	fprintf(f, "\t\t\t\"DefaultValue\" : %s\n",
    +#ifdef IS_ZWO
    +		"false"
    +#else
    +		"true"
    +#endif
    +		);
    +	fprintf(f, "\t\t},\n");
     
     	fprintf(f, "\t\t{\n");
     	fprintf(f, "\t\t\t\"Name\" : \"delayBetweenImages_ms\",\n");
    @@ -971,15 +1660,15 @@ void saveCameraInfo(ASI_CAMERA_INFO cameraInfo, char const *file, int width, int
     	fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "daymean");
     	fprintf(f, "\t\t\t\"MinValue\" : 0.0,\n");
     	fprintf(f, "\t\t\t\"MaxValue\" : 1.0,\n");
    -	fprintf(f, "\t\t\t\"DefaultValue\" : %.3f\n", CG.myModeMeanSetting.dayMean);
    +	fprintf(f, "\t\t\t\"DefaultValue\" : %.1f\n", CG.myModeMeanSetting.dayMean);
     	fprintf(f, "\t\t},\n");
     
     	fprintf(f, "\t\t{\n");
     	fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "DayMeanThreshold");
     	fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "daymeanthreshold");
     	fprintf(f, "\t\t\t\"MinValue\" : 0.01,\n");
    -	fprintf(f, "\t\t\t\"MaxValue\" : \"1.0\",\n");
    -	fprintf(f, "\t\t\t\"DefaultValue\" : %.3f\n", CG.myModeMeanSetting.dayMean_threshold);
    +	fprintf(f, "\t\t\t\"MaxValue\" : 1.0,\n");
    +	fprintf(f, "\t\t\t\"DefaultValue\" : %.2f\n", CG.myModeMeanSetting.dayMean_threshold);
     	fprintf(f, "\t\t},\n");
     
     	fprintf(f, "\t\t{\n");
    @@ -987,36 +1676,36 @@ void saveCameraInfo(ASI_CAMERA_INFO cameraInfo, char const *file, int width, int
     	fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "nightmean");
     	fprintf(f, "\t\t\t\"MinValue\" : 0.0,\n");
     	fprintf(f, "\t\t\t\"MaxValue\" : 1.0,\n");
    -	fprintf(f, "\t\t\t\"DefaultValue\" : %.3f\n", CG.myModeMeanSetting.nightMean);
    +	fprintf(f, "\t\t\t\"DefaultValue\" : %.1f\n", CG.myModeMeanSetting.nightMean);
     	fprintf(f, "\t\t},\n");
     
     	fprintf(f, "\t\t{\n");
     	fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "NightMeanThreshold");
     	fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "nightmeanthreshold");
     	fprintf(f, "\t\t\t\"MinValue\" : 0.01,\n");
    -	fprintf(f, "\t\t\t\"MaxValue\" : \"1.0\",\n");
    -	fprintf(f, "\t\t\t\"DefaultValue\" : %.3f\n", CG.myModeMeanSetting.nightMean_threshold);
    +	fprintf(f, "\t\t\t\"MaxValue\" : 1.0,\n");
    +	fprintf(f, "\t\t\t\"DefaultValue\" : %.2f\n", CG.myModeMeanSetting.nightMean_threshold);
     	fprintf(f, "\t\t},\n");
     
     	if (CG.isColorCamera) {
     		fprintf(f, "\t\t{\n");
     		fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "AutoWhiteBalance");
     		fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "awb");
    -		fprintf(f, "\t\t\t\"DefaultValue\" : 0\n");
    +		fprintf(f, "\t\t\t\"DefaultValue\" : false\n");
     		fprintf(f, "\t\t},\n");
     	}
     	if (CG.isCooledCamera) {
     		fprintf(f, "\t\t{\n");
     		fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "EnableCooler");
    -		fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "EnableCooler");
    -		fprintf(f, "\t\t\t\"DefaultValue\" : 0\n");
    +		fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "enablecooler");
    +		fprintf(f, "\t\t\t\"DefaultValue\" : false\n");
     		fprintf(f, "\t\t},\n");
     	}
     	if (CG.supportsTemperature) {
    -		fprintf(f, "\t\t{\n");
    +		fprintf(f, "\t\t{\n");	// TODO This will go away when the legacy overlay is removed
     		fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "showTemp");
    -		fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "showTemp");
    -		fprintf(f, "\t\t\t\"DefaultValue\" : %d\n", CG.overlay.showTemp ? 1 : 0);
    +		fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "showtemp");
    +		fprintf(f, "\t\t\t\"DefaultValue\" : %s\n", CG.overlay.showTemp ? "true" : "false");
     		fprintf(f, "\t\t},\n");
     	}
     	if (CG.supportsAggression) {
    @@ -1041,19 +1730,13 @@ void saveCameraInfo(ASI_CAMERA_INFO cameraInfo, char const *file, int width, int
     	fprintf(f, "\t\t{\n");
     	fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "autousb");
     	fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "autousb");
    -	fprintf(f, "\t\t\t\"DefaultValue\" : 1\n");
    +	fprintf(f, "\t\t\t\"DefaultValue\" : true\n");
     	fprintf(f, "\t\t},\n");
     
     	fprintf(f, "\t\t{\n");
     	fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "showUSB");
    -	fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "showUSB");
    -	fprintf(f, "\t\t\t\"DefaultValue\" : %d\n", CG.overlay.showUSB ? 1 : 0);
    -	fprintf(f, "\t\t},\n");
    -
    -	fprintf(f, "\t\t{\n");
    -	fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "experimentalExposure");
    -	fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "experimentalExposure");
    -	fprintf(f, "\t\t\t\"DefaultValue\" : \"%d\"\n", CG.HB.useExperimentalExposure ? 1 : 0);
    +	fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "showusb");
    +	fprintf(f, "\t\t\t\"DefaultValue\" : %s\n", CG.overlay.showUSB ? "true" : "false");
     	fprintf(f, "\t\t},\n");
     
     	fprintf(f, "\t\t{\n");
    @@ -1065,38 +1748,38 @@ void saveCameraInfo(ASI_CAMERA_INFO cameraInfo, char const *file, int width, int
     	fprintf(f, "\t\t{\n");
     	fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "showhistogrambox");
     	fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "showhistogrambox");
    -	fprintf(f, "\t\t\t\"DefaultValue\" : %d\n", CG.overlay.showHistogramBox ? 1 : 0);
    +	fprintf(f, "\t\t\t\"DefaultValue\" : %s\n", CG.overlay.showHistogramBox ? "true" : "false");
     	fprintf(f, "\t\t},\n");
     
     	fprintf(f, "\t\t{\n");
    -	fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "newexposure");
    -	fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "newexposure");
    -	fprintf(f, "\t\t\t\"DefaultValue\" : %d\n", CG.videoOffBetweenImages ? 1 : 0);
    -	fprintf(f, "\t\t},\n");
    -
    -	fprintf(f, "\t\t{\n");
    -	fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "CameraNumber");
    -	fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "cameraNumber");
    -	fprintf(f, "\t\t\t\"DefaultValue\" : %d\n", CG.cameraNumber);
    +	fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "ZWOexposureType");
    +	fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "zwoexposuretype");
    +	fprintf(f, "\t\t\t\"DefaultValue\" : %d\n", ZWOsnap);
     	fprintf(f, "\t\t},\n");
     #endif
     
     #ifdef IS_RPi
     	fprintf(f, "\t\t{\n");
     	fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "ExtraArguments");
    -	fprintf(f, "\t\t\t\"argumentName\" : \"%s\"\n", "extraArgs");
    +	fprintf(f, "\t\t\t\"argumentName\" : \"%s\"\n", "extraargs");
     	fprintf(f, "\t\t},\n");
     
     	if (CG.ct == ctRPi && CG.isLibcamera) {
     		fprintf(f, "\t\t{\n");
     		fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "TuningFile");
    -		fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "TuningFile");
    +		fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "tuningfile");
     		fprintf(f, "\t\t\t\"DefaultValue\" : \"\"\n");
     		fprintf(f, "\t\t},\n");
    +
    +		fprintf(f, "\t\t{\n");
    +		fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "Rotation");
    +		fprintf(f, "\t\t\t\"argumentName\" : \"%s\"\n", "rotation");
    +		fprintf(f, "\t\t},\n");
     	}
     #endif
     
     	double minGain=0.0, maxGain=0.0;
    +	int div_by;
     
     	for (int i = 0; i < iNumOfCtrl; i++)
     	{
    @@ -1107,9 +1790,9 @@ void saveCameraInfo(ASI_CAMERA_INFO cameraInfo, char const *file, int width, int
     				CG.ME, cameraInfo.CameraID, i, getRetCode(ret));
     			continue;
     		}
    -
    -		if (cc.ControlType > argumentNamesSize) {
    -			Log(0, "%s: ccControlType (%d) > argumentNamesSize (%d)\n",
    +// printf("iNumOfCtrl=%d, i=%d, cc.ControlType=%d, cc.Name=%s\n", iNumOfCtrl, i, cc.ControlType, cc.Name);
    +		if (cc.ControlType >= argumentNamesSize) {
    +			Log(0, "%s: ccControlType (%d) >= argumentNamesSize (%d)\n",
     				CG.ME, cc.ControlType, argumentNamesSize);
     // TODO: should we exit ??
     			continue;
    @@ -1117,28 +1800,56 @@ void saveCameraInfo(ASI_CAMERA_INFO cameraInfo, char const *file, int width, int
     
     		// blank names means it's unsupported
     		if (cc.Name[0] == '\0')
    +		{
    +// printf("cc.Name[0] is null, i=%d\n", i);
     			continue;
    +		}
     
     		// blank argument name means we don't have a command-line argument for it
    +// printf("getting a for ControlType %d (of %d), i=%d\n", cc.ControlType, argumentNamesSize, i);
     		char const *a =  argumentNames[cc.ControlType][1];
     		if (a == NULL || a[0] == '\0')
    +		{
    +// if (a == NULL) printf("a is null, i=%d\n", i);
    +// else printf("a[0] is null, i=%d\n", i);
     			continue;
    +		}
     
    -		int div_by = 1;
     		if (strcmp(cc.Name, "Exposure") == 0) {
    -			// The camera's values are in microseconds (us), but the WebUI displays in milliseconds (ms).
    +			// The camera's values are in microseconds (us),
    +			// but the WebUI displays in milliseconds (ms) so convert.
     			div_by = US_IN_MS;
    +		} else {
    +			div_by = 1;
     		}
    -		double min = cc.MinValue / (double) div_by;
    -		double max = cc.MaxValue / (double) div_by;
    -		double def = cc.DefaultValue / (double) div_by;
    +		double min = cc.MinValue / (double)div_by;
    +		double max = cc.MaxValue / (double)div_by;
    +		double def = cc.DefaultValue / (double)div_by;
    +#ifdef IS_ZWO
    +		if (strcmp(cc.Name, "AutoExpMaxExpMS") == 0) {
    +			// ZWO defaults for this setting are extremely low, so use the max value.
    +			def = cc.MaxValue / (double)div_by;
    +		}
    +#endif
    +
    +// XXXXXXXXX this is to help determine why some float settings are being output as integers
    +if (strcmp(cc.Name,"Gain") == 0 && CG.debugLevel >= 4)
    +{
    +printf("===== cc.MinValue=%1.2f, min=%1.2f   cc.MaxValue=%1.2f, max=%1.2f, iNumOfCtrl=%d\n",
    +(double) cc.MinValue, min, (double) cc.MaxValue, max, iNumOfCtrl);
    +printf("MinValue : %s,\n", LorF(min, "%ld", "%.3f"));
    +printf("MaxValue : %s,\n", LorF(max, "%ld", "%.3f"));
    +}
     
     		fprintf(f, "\t\t{\n");
     		fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", cc.Name);
     		fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", a);
     		fprintf(f, "\t\t\t\"MinValue\" : %s,\n", LorF(min, "%ld", "%.3f"));
     		fprintf(f, "\t\t\t\"MaxValue\" : %s,\n", LorF(max, "%ld", "%.3f"));
    -		fprintf(f, "\t\t\t\"DefaultValue\" : %s,\n", LorF(def, "%ld", "%.3f"));
    +		if (def == NO_DEFAULT)
    +			fprintf(f, "\t\t\t\"DefaultValue\" : \"none\",\n");
    +		else
    +			fprintf(f, "\t\t\t\"DefaultValue\" : %s,\n", LorF(def, "%ld", "%.3f"));
     		fprintf(f, "\t\t\t\"IsAutoSupported\" : %s,\n", cc.IsAutoSupported == ASI_TRUE ? "true" : "false");
     		fprintf(f, "\t\t\t\"IsWritable\" : %s,\n", cc.IsWritable == ASI_TRUE ? "true" : "false");
     		fprintf(f, "\t\t\t\"ControlType\" : %d\n", cc.ControlType);
    @@ -1163,17 +1874,6 @@ void saveCameraInfo(ASI_CAMERA_INFO cameraInfo, char const *file, int width, int
     	fprintf(f, "\t\t\t\"DefaultValue\" : %d\n", 10 * MS_IN_SEC);
     	fprintf(f, "\t\t},\n");
     
    -	fprintf(f, "\t\t{\n");
    -	fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "daymean");
    -	fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "daymean");
    -	fprintf(f, "\t\t\t\"DefaultValue\" : %f\n", CG.myModeMeanSetting.dayMean);
    -	fprintf(f, "\t\t},\n");
    -	fprintf(f, "\t\t{\n");
    -	fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "nightmean");
    -	fprintf(f, "\t\t\t\"argumentName\" : \"%s\",\n", "nightmean");
    -	fprintf(f, "\t\t\t\"DefaultValue\" : %f\n", CG.myModeMeanSetting.nightMean);
    -	fprintf(f, "\t\t},\n");
    -
     	// Set the day gain to the minimum possible.
     	fprintf(f, "\t\t{\n");
     	fprintf(f, "\t\t\t\"Name\" : \"%s\",\n", "daygain");
    @@ -1191,19 +1891,24 @@ void saveCameraInfo(ASI_CAMERA_INFO cameraInfo, char const *file, int width, int
     	// End the list
     	fprintf(f, "\t]\n");
     	fprintf(f, "}\n");
    -	fclose(f);
    +
    +	if (f != stdout)
    +		fclose(f);
     }
     
     // Output basic camera information.
    -void outputCameraInfo(ASI_CAMERA_INFO cameraInfo, config cg, long width, long height, double pixelSize, char const *bayer)
    +void outputCameraInfo(ASI_CAMERA_INFO cameraInfo, config cg,
    +	long width, long height, double pixelSize, char const *bayer)
     {
     	printf(" Camera Information:\n");
     	printf("  - Type: %s\n", CAMERA_TYPE);
    -	printf("  - Model: %s\n", getCameraModel(cameraInfo));
    +	printf("  - Model: %s (%s)\n", getCameraModel(cameraInfo.Name), cg.cm);
     #ifdef IS_ZWO
    -	printf("  - Camera ID: %s\n", cID);
    +	printf("  - ID: %s\n", cID);
     #endif
    -	printf("  - Camera Serial Number: %s\n", getSerialNumber(cameraInfo.CameraID));
    +	printf("  - Serial Number: %s\n", getSerialNumber(cameraInfo.CameraID));
    +	if (cg.cameraNumber > 0)
    +		printf("   Camera number: %d\n", cg.cameraNumber);
     	printf("  - Native Resolution: %ldx%ld\n", width, height);
     	printf("  - Pixel Size: %1.2f microns\n", pixelSize);
     	printf("  - Supported Bins: ");
    @@ -1438,16 +2143,6 @@ bool setDefaults(config *cg, ASI_CAMERA_INFO ci)
     	}
     
     	// The remaining settings are camera-specific and have camera defaults.
    -	ret = getControlCapForControlType(cg->cameraNumber, ASI_AUTO_TARGET_BRIGHTNESS, &cc);
    -	if (ret == ASI_SUCCESS)
    -	{
    -		cg->defaultBrightness = cc.DefaultValue;		// used elsewhere
    -		cg->dayBrightness = cc.DefaultValue;
    -		cg->nightBrightness = cc.DefaultValue;
    -	} else {
    -		Log(0, "%s: ASI_EXPOSURE failed with %s\n", cg->ME, getRetCode(ret));
    -		ok = false;
    -	}
     
     	// Get values used in several validations.
     	ret = getControlCapForControlType(cg->cameraNumber, ASI_EXPOSURE, &cc);
    @@ -1487,6 +2182,15 @@ bool validateSettings(config *cg, ASI_CAMERA_INFO ci)
     	ASI_CONTROL_CAPS cc;
     	bool ok = true;
     
    +	// If this camera model/name is different than the last one it likely means the settings
    +	// are the the last camera as well, so stop.
    +	char *model = getCameraModel(ci.Name);
    +	if (strcmp(model, cg->cm) != 0)
    +	{
    +		Log(0, "%s: ERROR: camera model changed; was [%s], now [%s].\n", cg->ME, cg->cm, model);
    +		closeUp(EXIT_ERROR_STOP);
    +	}
    +
     	// If an exposure value, which was entered on the command-line in MS, is out of range,
     	// we want to specify the valid range in MS, not US which we use internally.
     
    @@ -1515,19 +2219,28 @@ bool validateSettings(config *cg, ASI_CAMERA_INFO ci)
     	cg->nightExposure_us = cg->temp_nightExposure_ms * US_IN_MS;
     	cg->nightMaxAutoExposure_us = cg->temp_nightMaxAutoExposure_ms * US_IN_MS;
     
    -	if (! validateFloat(&cg->myModeMeanSetting.dayMean, cg->myModeMeanSetting.minMean, cg->myModeMeanSetting.maxMean, "Daytime Mean Target", false))
    +	if (! validateFloat(&cg->myModeMeanSetting.dayMean, cg->myModeMeanSetting.minMean,
    +			cg->myModeMeanSetting.maxMean, "Daytime Mean Target", false))
     		ok = false;
    -	if (! validateFloat(&cg->myModeMeanSetting.dayMean_threshold, cg->myModeMeanSetting.minMean_threshold, cg->myModeMeanSetting.maxMean_threshold, "Mean Threshold", false))
    +	if (! validateFloat(&cg->myModeMeanSetting.dayMean_threshold,
    +			cg->myModeMeanSetting.minMean_threshold, cg->myModeMeanSetting.maxMean_threshold,
    +			"Mean Threshold", false))
     		ok = false;
    -	if (! validateFloat(&cg->myModeMeanSetting.nightMean, cg->myModeMeanSetting.minMean, cg->myModeMeanSetting.maxMean, "Nighttime Mean Target", false))
    +	if (! validateFloat(&cg->myModeMeanSetting.nightMean, cg->myModeMeanSetting.minMean,
    +			cg->myModeMeanSetting.maxMean, "Nighttime Mean Target", false))
     		ok = false;
    -	if (! validateFloat(&cg->myModeMeanSetting.nightMean_threshold, cg->myModeMeanSetting.minMean_threshold, cg->myModeMeanSetting.maxMean_threshold, "Mean Threshold", false))
    +	if (! validateFloat(&cg->myModeMeanSetting.nightMean_threshold,
    +			cg->myModeMeanSetting.minMean_threshold, cg->myModeMeanSetting.maxMean_threshold,
    +			"Mean Threshold", false))
     		ok = false;
    -	if (! validateFloat(&cg->myModeMeanSetting.mean_p0, cg->myModeMeanSetting.minMean_p, cg->myModeMeanSetting.maxMean_p, "Mean p0", false))
    +	if (! validateFloat(&cg->myModeMeanSetting.mean_p0, cg->myModeMeanSetting.minMean_p,
    +			cg->myModeMeanSetting.maxMean_p, "Mean p0", false))
     		ok = false;
    -	if (! validateFloat(&cg->myModeMeanSetting.mean_p0, cg->myModeMeanSetting.minMean_p, cg->myModeMeanSetting.maxMean_p, "Mean p1", false))
    +	if (! validateFloat(&cg->myModeMeanSetting.mean_p0, cg->myModeMeanSetting.minMean_p,
    +			cg->myModeMeanSetting.maxMean_p, "Mean p1", false))
     		ok = false;
    -	if (! validateFloat(&cg->myModeMeanSetting.mean_p2, cg->myModeMeanSetting.minMean_p, cg->myModeMeanSetting.maxMean_p, "Mean p2", false))
    +	if (! validateFloat(&cg->myModeMeanSetting.mean_p2, cg->myModeMeanSetting.minMean_p,
    +			cg->myModeMeanSetting.maxMean_p, "Mean p2", false))
     		ok = false;
     
     	// If there's too short of a delay, pictures won't upload fast enough.
    @@ -1624,15 +2337,6 @@ bool validateSettings(config *cg, ASI_CAMERA_INFO ci)
     
     	// The remaining settings are camera-specific and have camera defaults.
     	// If the user didn't specify anything (i.e., the value is NOT_CHANGED), set it to the default.
    -	ret = getControlCapForControlType(cg->cameraNumber, ASI_AUTO_TARGET_BRIGHTNESS, &cc);
    -	if (ret == ASI_SUCCESS)
    -	{
    -		validateLong(&cg->dayBrightness, cc.MinValue, cc.MaxValue, "Daytime Brightness", true);
    -		validateLong(&cg->nightBrightness, cc.MinValue, cc.MaxValue, "Nighttime Brightness", true);
    -	} else if (ret != ASI_ERROR_INVALID_CONTROL_TYPE) {
    -		Log(0, "*** %s: ERROR: ASI_AUTO_TARGET_BRIGHTNESS failed with %s\n", cg->ME, getRetCode(ret));
    -		ok = false;
    -	}
     
     	ret = getControlCapForControlType(cg->cameraNumber, ASI_GAIN, &cc);
     	if (ret == ASI_SUCCESS)
    @@ -1758,18 +2462,6 @@ bool validateSettings(config *cg, ASI_CAMERA_INFO ci)
     			ok = false;
     		}
     
    -		ret = getControlCapForControlType(cg->cameraNumber, ASI_OFFSET, &cc);
    -		if (ret == ASI_SUCCESS)
    -		{
    -			if (cg->offset == NOT_CHANGED)
    -				cg->offset = cc.DefaultValue;
    -			else
    -				validateLong(&cg->offset, cc.MinValue, cc.MaxValue, "offset", true);
    -		} else if (ret != ASI_ERROR_INVALID_CONTROL_TYPE) {
    -			Log(0, "*** %s ERROR: ASI_OFFSET failed with %s\n", cg->ME, getRetCode(ret));
    -			ok = false;
    -		}
    -
     		if (cg->isCooledCamera && (cg->dayEnableCooler || cg->nightEnableCooler)) {
     			ret = getControlCapForControlType(cg->cameraNumber, ASI_TARGET_TEMP, &cc);
     			if (ret == ASI_SUCCESS)
    @@ -1777,12 +2469,14 @@ bool validateSettings(config *cg, ASI_CAMERA_INFO ci)
     				if (cg->dayTargetTemp == NOT_CHANGED)
     					cg->dayTargetTemp = cc.DefaultValue;
     				else
    -					validateLong(&cg->dayTargetTemp, cc.MinValue, cc.MaxValue, "Daytime Target Sensor Temperature", true);
    +					validateLong(&cg->dayTargetTemp, cc.MinValue, cc.MaxValue,
    +						"Daytime Target Sensor Temperature", true);
     
     				if (cg->nightTargetTemp == NOT_CHANGED)
     					cg->nightTargetTemp = cc.DefaultValue;
     				else
    -					validateLong(&cg->nightTargetTemp, cc.MinValue, cc.MaxValue, "Nighttime Target Sensor Temperature", true);
    +					validateLong(&cg->nightTargetTemp, cc.MinValue, cc.MaxValue,
    +						"Nighttime Target Sensor Temperature", true);
     			} else if (ret != ASI_ERROR_INVALID_CONTROL_TYPE) {
     				Log(0, "*** %s ERROR: ASI_TARGET_TEMP failed with %s\n", cg->ME, getRetCode(ret));
     				ok = false;
    @@ -1804,3 +2498,4 @@ bool validateSettings(config *cg, ASI_CAMERA_INFO ci)
     
     	return(ok);
     }
    +
    diff --git a/src/Makefile b/src/Makefile
    index 6a61e60dd..4b9eb647f 100644
    --- a/src/Makefile
    +++ b/src/Makefile
    @@ -85,17 +85,17 @@ endif
     
     CFLAGS += $(DEFS) $(ZWOSDK)
     
    -all:check_deps capture_ZWO capture_RPi startrails keogram sunwait
    +all:check_deps capture_ZWO capture_RPi startrails keogram sunwait uhubctl
     .PHONY : all
     
     ifneq ($(shell id -u), 0)
     deps:
    -	echo This must be ran with root permissions.
    +	echo This must be run with root permissions.
     	echo Please run 'sudo make deps'
     else
     deps:
     	@echo `date +%F\ %R:%S` Installing build dependencies...
    -	@apt update && apt -y install libopencv-dev libusb-dev libusb-1.0-0-dev ffmpeg gawk lftp jq imagemagick bc
    +	@apt update && apt -y install libopencv-dev libusb-dev libusb-1.0-0-dev ffmpeg lftp imagemagick bc
     endif
     
     .PHONY : deps
    @@ -118,6 +118,11 @@ sunwait:
     	@cp sunwait-src/sunwait .
     	@echo `date +%F\ %R:%S` Done.
     
    +# This is only needed for GitHub to compile
    +include/allsky_common.h: include/allsky_common.h.repo
    +	@echo creating fake $@ ...
    +	@cp include/allsky_common.h.repo $@
    +
     allsky_common.o: allsky_common.cpp include/allsky_common.h
     	@echo Building $@ ...
     	@$(CC) -c  allsky_common.cpp -o $@ $(CFLAGS) $(OPENCV)
    @@ -154,19 +159,26 @@ startrails:startrails.cpp
     	@$(CC) $@.cpp -o $@ $(CFLAGS) $(OPENCV)
     	@echo `date +%F\ %R:%S` Done.
     
    +uhubctl:uhubctl.c
    +	@echo `date +%F\ %R:%S` Building $@ program...
    +# This comes from the uhubctl Makefile:
    +	@cc -g -O0 -Wall -Wextra -std=c99 -pedantic -DPROGRAM_VERSION=\"2.5.0\" -I/usr/include/libusb-1.0 $@.c -o $@ -Wl,-zrelro,-znow -lusb-1.0
    +	@echo `date +%F\ %R:%S` Done.
    +
     symlink: all
     	@echo `date +%F\ %R:%S` Symlinking binaries...
     	@ln -s $$PWD/capture_ZWO ../bin/
     	@ln -s $$PWD/capture_RPi ../bin/
     	@ln -s $$PWD/keogram ../bin/
     	@ln -s $$PWD/startrails ../bin/
    +	@ln -s $$PWD/uhubctl ../bin/
     
     .PHONY: symlink
     
     ifneq ($(ROOTCHECK), 0)
     install uninstall:
     	@echo This must be run with root permissions.
    -	@echo Please run \'sudo make $@\'
    +	@echo Please run 'sudo make $@'
     else
     install:
     	@echo `date +%F\ %R:%S` Copying binaries...
    @@ -176,12 +188,14 @@ install:
     	  install capture_RPi $(DESTDIR)$(bindir); \
     	  install keogram $(DESTDIR)$(bindir); \
     	  install startrails $(DESTDIR)$(bindir); \
    +	  install uhubctl $(DESTDIR)$(bindir); \
     	else \
     	  [ ! -e ../bin ] && mkdir -p ../bin; \
     	  install -o $(SUDO_USER) -g $(SUDO_USER) capture_ZWO ../bin/; \
     	  install -o $(SUDO_USER) -g $(SUDO_USER) capture_RPi ../bin/; \
     	  install -o $(SUDO_USER) -g $(SUDO_USER) keogram ../bin/; \
     	  install -o $(SUDO_USER) -g $(SUDO_USER) startrails ../bin/; \
    +	  install -o $(SUDO_USER) -g $(SUDO_USER) uhubctl ../bin/; \
     	fi
     	@install sunwait $(DESTDIR)$(bindir)
     
    @@ -193,18 +207,20 @@ uninstall:
     	  rm -f $(DESTDIR)$(bindir)/keogram; \
     	  rm -f $(DESTDIR)$(bindir)/startrails; \
     	  rm -f $(DESTDIR)$(bindir)/sunwait; \
    +	  rm -f $(DESTDIR)$(bindir)/uhubctl; \
     	else \
     	  rm -f ../bin/capture_ZWO; \
     	  rm -f ../bin/capture_RPi; \
     	  rm -f ../bin/keogram; \
     	  rm -f ../bin/startrails; \
    +	  rm -f ../bin/uhubctl; \
     	fi
     
     endif # sudo / root check
     .PHONY : install uninstall
     
     clean:
    -	rm -f capture_ZWO capture_RPi startrails keogram sunwait *.o *.a
    +	rm -f capture_ZWO capture_RPi startrails keogram sunwait uhubctl *.o *.a
     .PHONY : clean
     
     endif # Correct directory structure check
    diff --git a/src/UHUBCTL_LICENSE b/src/UHUBCTL_LICENSE
    new file mode 100644
    index 000000000..e9f797c5d
    --- /dev/null
    +++ b/src/UHUBCTL_LICENSE
    @@ -0,0 +1,16 @@
    +uhubctl – USB hub per-port power control.
    +
    +Copyright (c) 2009-2023, Vadim Mikhailov
    +
    +This program is free software; you can redistribute it and/or modify
    +it under the terms of the GNU General Public License as published by
    +the Free Software Foundation, version 2.
    +
    +This program is distributed in the hope that it will be useful,
    +but WITHOUT ANY WARRANTY; without even the implied warranty of
    +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    +GNU General Public License for more details.
    +
    +You should have received a copy of the GNU General Public License along
    +with this program; if not, write to the Free Software Foundation, Inc.,
    +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
    diff --git a/src/allsky_common.cpp b/src/allsky_common.cpp
    index 4394874e3..a252560f6 100644
    --- a/src/allsky_common.cpp
    +++ b/src/allsky_common.cpp
    @@ -16,6 +16,7 @@
     #include <fstream>
     #include <stdarg.h>
     #include <sys/types.h>
    +#include <typeinfo>
     #include <sys/wait.h>
     #include <stdio.h>
     #include <fcntl.h>
    @@ -39,7 +40,7 @@ static char const *fontnames[]		= {		// Character representation of names for cl
     **/
     void Log(int required_level, const char *fmt, ...)
     {
    -	if ((int)abs(CG.debugLevel) >= required_level) {
    +	if (CG.debugLevel >= (int)abs(required_level)) {
     		char msg[1000];
     		va_list va;
     		va_start(va, fmt);
    @@ -235,33 +236,24 @@ void add_variables_to_command(config cg, char *cmd, timeval startDateTime)
     		strcat(cmd, tmp);
     	}
     
    -	snprintf(tmp, s, " AUTOWB=%d", cg.currentAutoAWB ? 1 : 0);
    -	strcat(cmd, tmp);
    -	snprintf(tmp, s, " sAUTOAWB='%s'", cg.currentAutoAWB ? "(auto)" : "");
    -	strcat(cmd, tmp);
    -	if (cg.lastWBR >= 0.0) {
    -		snprintf(tmp, s, " WBR=%s", LorF(cg.lastWBR, "%d", "%f"));
    -		strcat(cmd, tmp);
    -	}
    -	if (cg.lastWBB >= 0.0) {
    -		snprintf(tmp, s, " WBB=%s", LorF(cg.lastWBB, "%d", "%f"));
    -		strcat(cmd, tmp);
    -	}
    -
    -	if (cg.currentBrightness >= 0) {
    -		snprintf(tmp, s, " BRIGHTNESS=%ld", cg.currentBrightness);
    +	if (cg.isColorCamera)
    +	{
    +		snprintf(tmp, s, " AUTOWB=%d", cg.currentAutoAWB ? 1 : 0);
     		strcat(cmd, tmp);
    -	}
    -
    -	if (cg.lastMean >= 0.0) {
    -		snprintf(tmp, s, " MEAN=%s", LorF(cg.lastMean, "%d", "%f"));
    -		strcat(cmd, tmp);
    -	}
    -	// FULLMEAN is to see if the mean of the whole image is the same as the mean returned
    -	// by removeBadImages.sh; if so, removeBadImages.sh doesn't need to determine the mean.
    -	if (cg.lastMeanFull >= 0.0) {
    -		snprintf(tmp, s, " FULLMEAN=%s", LorF(cg.lastMeanFull, "%d", "%f"));
    +		snprintf(tmp, s, " sAUTOAWB='%s'", cg.currentAutoAWB ? "(auto)" : "");
     		strcat(cmd, tmp);
    +		if (cg.lastWBR >= 0.0) {
    +			snprintf(tmp, s, " WBR=%s", LorF(cg.lastWBR, "%d", "%f"));
    +			strcat(cmd, tmp);
    +		}
    +		if (cg.lastWBB >= 0.0) {
    +			snprintf(tmp, s, " WBB=%s", LorF(cg.lastWBB, "%d", "%f"));
    +			strcat(cmd, tmp);
    +		}
    +		if (cg.lastMean >= 0.0) {
    +			snprintf(tmp, s, " MEAN=%f", cg.lastMean);
    +			strcat(cmd, tmp);
    +		}
     	}
     
     	// Since negative temperatures are valid, check against an impossible temperature.
    @@ -421,44 +413,52 @@ std::string calculateDayOrNight(const char *latitude, const char *longitude, flo
     	return("");
     }
     
    -// Calculate how long until nighttime.
    -int calculateTimeToNightTime(const char *latitude, const char *longitude, float angle)
    +// Calculate how long until daytime (isDaytime==false) or nighttime (isDaytime==true).
    +int calculateTimeToNextTime(const char *latitude, const char *longitude, float angle, bool isDaytime)
     {
    +	// We are sleeping UNTIL which time?
    +	const char *toTime;
    +	if (isDaytime) toTime = "night";
    +	else toTime = "day";
    +
     	std::string t;
    -	char sunwaitCommand[128];	// returns "hh:mm"
    +	char sunwaitCommand[128];	// returns "hh:mm, hh:mm"  (daytime begin, nighttime begin)
     	snprintf(sunwaitCommand, sizeof(sunwaitCommand),
    -		"sunwait list set angle %s %s %s",
    +		"sunwait list %s angle %s %s %s",
    +		isDaytime ? "set" : "rise",
     		convertCommaToPeriod(angle, "%.4f"), latitude, longitude);
     	t = exec(sunwaitCommand);
    -
     	t.erase(std::remove(t.begin(), t.end(), '\n'), t.end());
     
    -	int hNight=0, mNight=0, secsNight;
    +	int hNext=0, mNext=0;		// hours plus minutes to the next time
     	// It's possible for sunwait to return "--:--" if the angle causes sunset to start
     	// after midnight or before noon.
    -	if (sscanf(t.c_str(), "%d:%d", &hNight, &mNight) != 2)
    +	if (sscanf(t.c_str(), "%d:%d", &hNext, &mNext) != 2)
     	{
    -		Log(0, "*** %s: ERROR: With angle %.4f sunwait returned unknown time to nighttime: %s\n",
    -			CG.ME, angle, t.c_str());
    +		Log(0, "*** %s: ERROR: With angle %.4f sunwait returned unknown time to %stime: %s\n",
    +			CG.ME, angle, toTime, t.c_str());
     		return(1 * S_IN_HOUR);	// 1 hour - should we exit instead?
     	}
    -	secsNight = (hNight * S_IN_HOUR) + (mNight * S_IN_MIN);	// secs to nighttime from start of today
    +
    +	// Total seconds to nextTime from start of today.
     	// sunwait doesn't return seconds so on average the actual time will be 30 seconds
    -	// after the stated time. So, add 30 seconds.
    -	secsNight += 30;
    +	// after the stated time, So add 30 seconds.
    +	int sNext = (hNext * S_IN_HOUR) + (mNext * S_IN_MIN) + 30;
     
    +	// Now get how long from NOW the next time is.
     	char *now = getTime("%H:%M:%S");
    -	int hNow=0, mNow=0, sNow=0, secsNow;
    +	int hNow=0, mNow=0, sNow=0;
     	sscanf(now, "%d:%d:%d", &hNow, &mNow, &sNow);
    -	secsNow = (hNow*S_IN_HOUR) + (mNow*S_IN_MIN) + sNow;	// seconds to now from start of today
    -	Log(4, "Now=%s, nighttime starts at %s\n", now, t.c_str());
    +	// Convert to total seconds to now from start of today
    +	sNow = (hNow*S_IN_HOUR) + (mNow*S_IN_MIN) + sNow;
    +	Log(4, "Now=%s, %stime starts at %s\n", now, toTime, t.c_str());
     
    -	// Handle the (probably rare) case where nighttime is tomorrow.
    -	// We are only called during the day, so if nighttime is earlier than now, it was past midnight.
    -	int diff_s = secsNight - secsNow;
    +	// Handle the (probably rare) case where nighttime/daytime is tomorrow.
    +	// If nighttime is earlier than now, it was past midnight.
    +	int diff_s = sNext - sNow;
     	if (diff_s < 0)
     	{
    -		// This assumes tomorrow's nighttime starts same as today's, which is close enough.
    +		// This assumes tomorrow's nighttime/daytime starts same as today's, which is close enough.
     		return(diff_s + S_IN_DAY);	// Add one day
     	}
     	else
    @@ -651,18 +651,9 @@ int doOverlay(cv::Mat image, config cg, char *startTime, int gainChange)
     		iYOffset += cg.overlay.iTextLineHeight;
     	}
     
    -	if (cg.overlay.showBrightness)
    -	{
    -		sprintf(tmp, "Brightness: %ld", cg.currentBrightness);
    -		cvText(image, tmp, cg.overlay.iTextX, cg.overlay.iTextY + (iYOffset / cg.currentBin),
    -			cg.overlay.fontsize * SMALLFONTSIZE_MULTIPLIER, cg.overlay.linewidth,
    -			lineType, font, cg.overlay.smallFontcolor, cg.imageType, cg.overlay.outlinefont, cg.width);
    -		iYOffset += cg.overlay.iTextLineHeight;
    -	}
    -
    -	if (cg.overlay.showMean && cg.lastMean != 1)
    +	if (cg.overlay.showMean && cg.lastMean != 1.0)
     	{
    -		snprintf(tmp, sizeof(tmp), "Mean: %s", LorF(cg.lastMean, "%d", "%.3f"));
    +		snprintf(tmp, sizeof(tmp), "Mean: %.3f", cg.lastMean);
     		cvText(image, tmp, cg.overlay.iTextX, cg.overlay.iTextY + (iYOffset / cg.currentBin),
     			cg.overlay.fontsize * SMALLFONTSIZE_MULTIPLIER, cg.overlay.linewidth,
     			lineType, font, cg.overlay.smallFontcolor, cg.imageType, cg.overlay.outlinefont, cg.width);
    @@ -810,12 +801,13 @@ void closeUp(int e)
     
     	closingUp = true;
     
    -	stopVideoCapture(CG.cameraNumber);
    -	// Seems to hang on ASICloseCamera() if taking a picture when the signal is sent,
    -	// until the exposure finishes, then it never returns so the remaining code doesn't
    -	// get executed. Don't know a way around that, so don't bother closing the camera.
    -	// Prior versions of allsky didn't do any cleanup, so it should be ok not to close the camera.
    -	//	ASICloseCamera(CG.cameraNumber);
    +	if (CG.ct == ctZWO)
    +	{
    +		if (CG.ZWOexposureType == ZWOsnap)
    +			(void) stopExposure(CG.cameraNumber);
    +		else
    +			(void) stopVideoCapture(CG.cameraNumber);
    +	}
     
     	// Close the optional display window.	// not used by RPi
     	if (bDisplay)
    @@ -825,23 +817,29 @@ void closeUp(int e)
     		pthread_join(threadDisplay, &retval);
     	}
     
    -	char const *a = "Stopping";
    +	char const *a = e == EXIT_RESTARTING ? "Restarting" : "Stopping";
    +
     	if (CG.notificationImages) {
     		if (e == EXIT_RESTARTING)
    -		{
     			(void) displayNotificationImage("--expires 15 Restarting &");
    -			a = "Restarting";
    -		}
    -		else
    -		{
    +		else if (e == EXIT_OK)
     			(void) displayNotificationImage("--expires 2 NotRunning &");
    -		}
    +		else
    +			(void) displayNotificationImage("--expires 0 Error &");
    +
     		// Sleep to give it a chance to print any messages so they (hopefully) get printed
     		// before the one below. This is only so it looks nicer in the log file.
     		sleep(3);
     	}
     
    -	printf("     ***** %s AllSky *****\n", a);
    +	printf("     ***** %s Allsky *****\n", a);
    +
    +	// ZWO seems to hang on ASICloseCamera() if taking a picture when the signal is sent,
    +	// until the exposure finishes, then it never returns so the remaining code doesn't
    +	// get executed. Don't know how to get around that - hopefully this works:
    +	if (CG.ct == ctZWO && ! gotSignal && e != EXIT_NO_CAMERA)
    +		(void) closeCamera(CG.cameraNumber);
    +
     	exit(e);
     }
     
    @@ -909,7 +907,7 @@ bool validateLong(long *num, long min, long max, char const *name, bool invalidI
     bool validateFloat(double *num, double min, double max, char const *name, bool invalidIsOK)
     {
     	if (*num < min) {
    -		fprintf(stderr, "*** %s: '%s' (%'.1f) is less than the minimum of %'.1f",
    +		fprintf(stderr, "*** %s: '%s' (%'.3f) is less than the minimum of %'.3f",
     			invalidIsOK ? "WARNING" : "ERROR", name, *num, min);
     		if (invalidIsOK == true)
     		{
    @@ -920,7 +918,7 @@ bool validateFloat(double *num, double min, double max, char const *name, bool i
     		return invalidIsOK;
     
     	} else if (*num > max) {
    -		fprintf(stderr, "*** %s: '%s' (%'.1f) is greater than the maximum of %'.1f",
    +		fprintf(stderr, "*** %s: '%s' (%'.3f) is greater than the maximum of %'.3f",
     			invalidIsOK ? "WARNING" : "ERROR", name, *num, max);
     		if (invalidIsOK == true)
     		{
    @@ -953,7 +951,7 @@ void displayHeader(config cg)
     		printf("Capture images of the sky with a Raspberry Pi and an RPi camera\n");
     	printf("%s\n", c(KNRM));
     
    -	if (! cg.help) printf("%sAdd -h or --help for available options%s\n\n", c(KYEL), c(KNRM));
    +	if (! cg.help) printf("%sAdd --help for available options%s\n\n", c(KYEL), c(KNRM));
     	printf("Author: Thomas Jacquin - <jacquin.thomas@gmail.com>\n\n");
     	printf("Contributors:\n");
     	printf(" -Knut Olav Klo\n");
    @@ -988,14 +986,15 @@ void displayHelp(config cg)
     	printf("  %-*s   command-line arguments.  The file is read when seen on the command line [none].\n", n, "");
     
     	printf("\nDaytime settings:\n");
    +	printf(" -%-*s - 1 enables capturing of daytime images [%s].\n", n, "takedaytimeimages b", yesNo(cg.daytimeCapture));
    +	printf(" -%-*s - 1 enables saving of daytime images [%s].\n", n, "savedaytimeimages b", yesNo(cg.daytimeSave));
     	printf(" -%-*s - 1 enables daytime auto-exposure [%s].\n", n, "dayautoexposure b", yesNo(cg.dayAutoExposure));
     	printf(" -%-*s - Maximum daytime auto-exposure in ms.\n", n, "daymaxexposure n");
     	printf(" -%-*s - Daytime exposure in us [%'ld].\n", n, "dayexposure n", cg.dayExposure_us);
     	printf(" -%-*s - Daytime mean target brightness [%.2f].\n", n, "daymean", cg.myModeMeanSetting.dayMean);
    -	printf(" -%-*s - Daytime mean target threshold [%.2f].\n", n, "daymean-threshold n", cg.myModeMeanSetting.dayMean_threshold);
    +	printf(" -%-*s - Daytime mean target threshold [%.2f].\n", n, "daymeanthreshold n", cg.myModeMeanSetting.dayMean_threshold);
     	printf("  %-*s   NOTE: Daytime auto-gain and auto-exposure should be on for best results.\n", n, "");
    -	printf(" -%-*s - Daytime brightness change [%'ld].\n", n, "daybrightness n", cg.dayBrightness);
    -	printf(" -%-*s - Delay between daytime images in ms [%'ld].\n", n, "dayDelay n", cg.dayDelay_ms);
    +	printf(" -%-*s - Delay between daytime images in ms [%'ld].\n", n, "daydelay n", cg.dayDelay_ms);
     	printf(" -%-*s - 1 enables daytime auto gain [%s].\n", n, "dayautogain b", yesNo(cg.dayAutoGain));
     	printf(" -%-*s - Daytime maximum auto gain.\n", n, "daymaxautogain n");
     	printf(" -%-*s - Daytime gain.\n", n, "daygain n");
    @@ -1005,22 +1004,23 @@ void displayHelp(config cg)
     	printf(" -%-*s - Manual White Balance Blue.\n", n, "daywbb n");
     	printf(" -%-*s - Number of auto-exposure frames to skip when starting software during daytime [%ld].\n", n, "dayskipframes n", cg.daySkipFrames);
     	if (cg.ct == ctZWO) {
    -		printf(" -%-*s - 1 enables cooler (cooled cameras only) [%s].\n", n, "dayEnableCooler b", yesNo(cg.dayEnableCooler));
    -		printf(" -%-*s - Target temperature in degrees C (cooled cameras only).\n", n, "dayTargetTemp n");
    +		printf(" -%-*s - 1 enables cooler (cooled cameras only) [%s].\n", n, "dayenablecooler b", yesNo(cg.dayEnableCooler));
    +		printf(" -%-*s - Target temperature in degrees C (cooled cameras only).\n", n, "daytargettemp n");
     	}
     	if (cg.ct == ctRPi && cg.isLibcamera) {
    -		printf(" -%-*s - Name of the day camera tuning file to use [%s].\n", n, "dayTuningFile s", "none");
    +		printf(" -%-*s - Name of the day camera tuning file to use [%s].\n", n, "daytuningfile s", "none");
     	}
     
     	printf("\nNighttime settings:\n");
    +	printf(" -%-*s - 1 enables capturing of nighttime images [%s].\n", n, "takenighttimeimages b", yesNo(cg.nighttimeCapture));
    +	printf(" -%-*s - 1 enables saving of nighttime images [%s].\n", n, "savenighttimeimages b", yesNo(cg.nighttimeSave));
     	printf(" -%-*s - 1 enables nighttime auto-exposure [%s].\n", n, "nightautoexposure b", yesNo(cg.nightAutoExposure));
     	printf(" -%-*s - Maximum nighttime auto-exposure in ms.\n", n, "nightmaxexposure n");
     	printf(" -%-*s - Nighttime exposure in us [%'ld].\n", n, "nightexposure n", cg.nightExposure_us);
     	printf(" -%-*s - Nighttime mean target brightness [%.2f].\n", n, "nightmean n", cg.myModeMeanSetting.nightMean);
     	printf("  %-*s   NOTE: Nighttime auto-gain and auto-exposure should be on for best results.\n", n, "");
    -	printf(" -%-*s - Nighttime mean target threshold [%.2f].\n", n, "nightmean-threshold n", cg.myModeMeanSetting.nightMean_threshold);
    -	printf(" -%-*s - Nighttime brightness change [%ld].\n", n, "nightbrightness n n", cg.nightBrightness);
    -	printf(" -%-*s - Delay between nighttime images in ms [%'ld].\n", n, "nightDelay n", cg.nightDelay_ms);
    +	printf(" -%-*s - Nighttime mean target threshold [%.2f].\n", n, "nightmeanthreshold n", cg.myModeMeanSetting.nightMean_threshold);
    +	printf(" -%-*s - Delay between nighttime images in ms [%'ld].\n", n, "nightdelay n", cg.nightDelay_ms);
     	printf(" -%-*s - 1 enables nighttime auto gain [%s].\n", n, "nightautogain b", yesNo(cg.nightAutoGain));
     	printf(" -%-*s - Nighttime maximum auto gain.\n", n, "nightmaxautogain n");
     	printf(" -%-*s - Nighttime gain.\n", n, "nightgain n");
    @@ -1030,11 +1030,11 @@ void displayHelp(config cg)
     	printf(" -%-*s - Manual White Balance Blue.\n", n, "nightwbb n");
     	printf(" -%-*s - Number of auto-exposure frames to skip when starting software during nighttime [%ld].\n", n, "nightskipframes n", cg.nightSkipFrames);
     	if (cg.ct == ctZWO) {
    -		printf(" -%-*s - 1 enables cooler (cooled cameras only) [%s]\n", n, "nightEnableCooler b", yesNo(cg.nightEnableCooler));
    -		printf(" -%-*s - Target temperature in degrees C (cooled cameras only).\n", n, "nightTargetTemp n");
    +		printf(" -%-*s - 1 enables cooler (cooled cameras only) [%s]\n", n, "nightenablecooler b", yesNo(cg.nightEnableCooler));
    +		printf(" -%-*s - Target temperature in degrees C (cooled cameras only).\n", n, "nighttargettemp n");
     	}
     	if (cg.ct == ctRPi && cg.isLibcamera) {
    -		printf(" -%-*s - Name of the night camera tuning file to use [%s].\n", n, "nightTuningFile s", "none");
    +		printf(" -%-*s - Name of the night camera tuning file to use [%s].\n", n, "nighttuningfile s", "none");
     	}
     
     	printf("\nDay and nighttime settings:\n");
    @@ -1045,7 +1045,6 @@ void displayHelp(config cg)
     	}
     	if (cg.ct == ctZWO) {
     		printf(" -%-*s - Gamma level.\n", n, "gamma n");
    -		printf(" -%-*s - Offset.\n", n, "offset n");
     		printf(" -%-*s - Percent of exposure change to make, similar to PHD2 [%ld%%].\n", n, "aggression n", cg.aggression);
     		printf(" -%-*s - Seconds to transition gain from day-to-night or night-to-day.  0 disable it [%'ld].\n", n, "gaintransitiontime n", cg.gainTransitionTime);
     	}
    @@ -1065,14 +1064,14 @@ void displayHelp(config cg)
     			printf(" -%-*s - Amount to rotate image in degrees - 0, 90, 180, or 270 [%ld].\n", n, "rotation n", cg.rotation);
     	}
     	printf(" -%-*s - 0 = No flip, 1 = Horizontal, 2 = Vertical, 3 = Both [%ld].\n", n, "flip n", cg.flip);
    -	printf(" -%-*s - 1 enables consistent delays between images [%s].\n", n, "consistentDelays b", yesNo(cg.consistentDelays));
    +	printf(" -%-*s - 1 enables focus mode [%s].\n", n, "determinefocus b", yesNo(cg.determineFocus));
    +	printf(" -%-*s - 1 enables consistent delays between images [%s].\n", n, "consistentdelays b", yesNo(cg.consistentDelays));
     	printf(" -%-*s - Format the time is displayed in [%s].\n", n, "timeformat s", cg.timeFormat);
     	printf(" -%-*s - 1 enables notification images, for example, 'Camera is off during day' [%s].\n", n, "notificationimages b", yesNo(cg.notificationImages));
     	printf(" -%-*s - Latitude of the camera [no default - you must set it].\n", n, "latitude s");
     	printf(" -%-*s - Longitude of the camera [no default - you must set it].\n", n, "longitude s");
     	printf(" -%-*s - Angle of the sun below the horizon [%.2f].\n", n, "angle n", cg.angle);
     	printf("  %-*s   -6 = civil twilight   -12 = nautical twilight   -18 = astronomical twilight.\n", n, "");
    -	printf(" -%-*s - 1 enables capturing of daytime images [%s].\n", n, "takeDaytimeImages b", yesNo(cg.daytimeCapture));
     	printf(" -%-*s - 1 takes dark frames [%s].\n", n, "takeDarkFrames b", yesNo(cg.takeDarkFrames));
     	printf(" -%-*s - Your locale - to determine thousands separator and decimal point [%s].\n", n, "locale s", "locale on Pi");
     	printf("  %-*s   Type 'locale' at a command prompt to determine yours.\n", n, "");
    @@ -1080,24 +1079,22 @@ void displayHelp(config cg)
     		printf(" -%-*s - Default = %d %d %0.2f %0.2f (box width X, box width y, X offset percent (0-100), Y offset (0-100))\n", n, "histogrambox n n n n", cg.HB.histogramBoxSizeX, cg.HB.histogramBoxSizeY, cg.HB.histogramBoxPercentFromLeft * 100.0, cg.HB.histogramBoxPercentFromTop * 100.0);
     		printf(" -%-*s - 1 enables auto USB Speed.\n", n, "autousb b");
     		printf(" -%-*s - USB bandwidth percent.\n", n, "usb n");
    -		printf(" -%-*s - 1 enables a newer ZWO auto-exposure algorithm [%s].\n", n, "experimentalExposure b", yesNo(cg.HB.useExperimentalExposure));
    -		printf(" -%-*s - Determines if version 0.8 exposure method should be used [%s].\n", n, "newexposure b", yesNo(cg.videoOffBetweenImages));
    +		printf(" -%-*s - Determines what type of exposure ZWO cameras should use [%s].\n", n, "zwoexposuretype n", getZWOexposureType(ZWOsnap));
     	}
     	if (cg.ct == ctRPi) {
    -		printf(" -%-*s - Extra arguments pass to image capture program [%s].\n", n, "extraArgs s", cg.extraArgs);
    +		printf(" -%-*s - Extra arguments pass to image capture program [%s].\n", n, "extraargs s", cg.extraArgs);
     	}
     	printf(" -%-*s - Set to 1, 2, 3, or 4 for more debugging information [%ld].\n", n, "debuglevel n", cg.debugLevel);
     
     	printf("\nOverlay settings:\n");
    -	printf(" -%-*s - Set to %d to use the new, enhanced 'module' overlay program [%s].\n", n, "overlayMethod n", OVERLAY_METHOD_LEGACY, getOverlayMethod(cg.overlay.overlayMethod).c_str());
    -	printf(" -%-*s - Set to 1 to display the time [%s].\n", n, "showTime b", yesNo(cg.overlay.showTime));
    +	printf(" -%-*s - Set to %d to use the new, enhanced 'module' overlay program [%s].\n", n, "overlaymethod n", OVERLAY_METHOD_LEGACY, getOverlayMethod(cg.overlay.overlayMethod).c_str());
    +	printf(" -%-*s - Set to 1 to display the time [%s].\n", n, "showtime b", yesNo(cg.overlay.showTime));
     	printf(" -%-*s - Units to display temperature in: 'C'elsius, 'F'ahrenheit, or 'B'oth [%s].\n", n, "temptype s", cg.tempType);
    -	printf(" -%-*s - 1 displays the exposure length [%s].\n", n, "showExposure b", yesNo(cg.overlay.showExposure));
    -	printf(" -%-*s - 1 displays the camera sensor temperature [%s].\n", n, "showTemp b", yesNo(cg.overlay.showTemp));
    -	printf(" -%-*s - 1 displays the gain [%s].\n", n, "showGain b", yesNo(cg.overlay.showGain));
    -	printf(" -%-*s - 1 displays the brightness [%s].\n", n, "showBrightness b", yesNo(cg.overlay.showBrightness));
    -	printf(" -%-*s - 1 displays the mean brightness used in auto-exposure [%s].\n", n, "showMean b", yesNo(cg.overlay.showMean));
    -	printf(" -%-*s - 1 displays a focus metric - the higher the number the better focus [%s].\n", n, "showFocus b", yesNo(cg.overlay.showFocus));
    +	printf(" -%-*s - 1 displays the exposure length [%s].\n", n, "showexposure b", yesNo(cg.overlay.showExposure));
    +	printf(" -%-*s - 1 displays the camera sensor temperature [%s].\n", n, "showtemp b", yesNo(cg.overlay.showTemp));
    +	printf(" -%-*s - 1 displays the gain [%s].\n", n, "showgain b", yesNo(cg.overlay.showGain));
    +	printf(" -%-*s - 1 displays the mean brightness used in auto-exposure [%s].\n", n, "showmean b", yesNo(cg.overlay.showMean));
    +	printf(" -%-*s - 1 displays a focus metric - the higher the number the better focus [%s].\n", n, "showfocus b", yesNo(cg.overlay.showFocus));
     	if (cg.ct == ctZWO) {
     		printf(" -%-*s - 1 displays an outline of the histogram box.\n", n, "showhistogrambox b");
     		printf("  %-*s   Useful to determine what parameters to use with -histogrambox.\n", n, "");
    @@ -1117,7 +1114,8 @@ void displayHelp(config cg)
     	printf(" -%-*s - 1 enables outline font [%s].\n", n, "outlinefont b", yesNo(cg.overlay.outlinefont));
     
     	printf("\nMisc. settings:\n");
    -	printf(" -%-*s - Camera number [%d].\n", n, "cameraID n", cg.cameraNumber);
    +	printf(" -%-*s - Last camera model [no default].\n", n, "cameramodel s");
    +	printf(" -%-*s - Camera number [%d].\n", n, "cameranumber n", cg.cameraNumber);
     	printf(" -%-*s - Where to save 'filename' [%s].\n", n, "save_dir s", cg.saveDir);
     	printf(" -%-*s - 1 previews the captured images. Only works with a Desktop Environment [%s]\n", n, "preview", yesNo(cg.preview));
     	printf(" -%-*s - Outputs the camera's capabilities to the specified file and exists.\n", n, "cc_file s");
    @@ -1169,8 +1167,6 @@ void displaySettings(config cg)
     	printf("%s", c(KGRN));
     	printf("\nSettings:\n");
     
    -	if (cg.cameraNumber > 0)
    -		printf("   Camera number: %d\n", cg.cameraNumber);
     	if (cg.cmdToUse != NULL)
     		printf("   Command: %s\n", cg.cmdToUse);
     	printf("   Image Type: %s (%ld)\n", cg.sType, cg.imageType);
    @@ -1178,6 +1174,9 @@ void displaySettings(config cg)
     	printf("   Configuration file: %s\n", stringORnone(cg.configFile));
     	printf("   Quality: %ld\n", cg.userQuality);
     	printf("   Daytime capture: %s\n", yesNo(cg.daytimeCapture));
    +	printf("   Daytime save: %s\n", yesNo(cg.daytimeSave));
    +	printf("   Nighttime capture: %s\n", yesNo(cg.nighttimeCapture));
    +	printf("   Nighttime save: %s\n", yesNo(cg.nighttimeSave));
     
     	printf("   Exposure (day):   %15s, Auto: %3s", length_in_units(cg.dayExposure_us, true), yesNo(cg.dayAutoExposure));
     		if (cg.dayAutoExposure)
    @@ -1198,19 +1197,17 @@ void displaySettings(config cg)
     	if (cg.gainTransitionTimeImplemented)
     		printf("   Gain Transition Time: %.1f minutes\n", (float) cg.gainTransitionTime/60);
     
    -	printf("   Target Mean Value (day):       %1.3f\n", cg.myModeMeanSetting.dayMean);
    -	printf("   Target Mean Value (night):     %1.3f\n", cg.myModeMeanSetting.nightMean);
    -	printf("   Target Mean Threshold (day):   %1.3f\n", cg.myModeMeanSetting.dayMean_threshold);
    -	printf("   Target Mean Threshold (night): %1.3f\n", cg.myModeMeanSetting.nightMean_threshold);
    +	printf("   Target Mean Value (day):       %.3f\n", cg.myModeMeanSetting.dayMean);
    +	printf("   Target Mean Value (night):     %.3f\n", cg.myModeMeanSetting.nightMean);
    +	printf("   Target Mean Threshold (day):   %.3f\n", cg.myModeMeanSetting.dayMean_threshold);
    +	printf("   Target Mean Threshold (night): %.3f\n", cg.myModeMeanSetting.nightMean_threshold);
     	if (cg.supportsMyModeMean)
     	{
    -		printf("      p0: %1.3f\n", cg.myModeMeanSetting.mean_p0);
    -		printf("      p1: %1.3f\n", cg.myModeMeanSetting.mean_p1);
    -		printf("      p2: %1.3f\n", cg.myModeMeanSetting.mean_p2);
    +		printf("      p0: %.3f\n", cg.myModeMeanSetting.mean_p0);
    +		printf("      p1: %.3f\n", cg.myModeMeanSetting.mean_p1);
    +		printf("      p2: %.3f\n", cg.myModeMeanSetting.mean_p2);
     	}
     
    -	printf("   Brightness (day):   %ld\n", cg.dayBrightness);
    -	printf("   Brightness (night): %ld\n", cg.nightBrightness);
     	printf("   Binning (day):   %ld\n", cg.dayBin);
     	printf("   Binning (night): %ld\n", cg.nightBin);
     	if (cg.isColorCamera) {
    @@ -1234,7 +1231,6 @@ void displaySettings(config cg)
     	}
     	if (cg.ct == ctZWO) {
     		if (cg.gamma != NOT_CHANGED) printf("   Gamma: %ld\n", cg.gamma);
    -		if (cg.offset != NOT_CHANGED) printf("   Offset: %ld\n", cg.offset);
     		if (cg.asiBandwidth != NOT_CHANGED) printf("   USB Speed: %ld, auto: %s\n", cg.asiBandwidth, yesNo(cg.asiAutoBandwidth));
     	}
     	if (cg.ct == ctRPi) {
    @@ -1257,10 +1253,10 @@ void displaySettings(config cg)
     			cg.HB.histogramBoxSizeX, cg.HB.histogramBoxSizeY,
     			cg.HB.histogramBoxPercentFromLeft * 100.0, cg.HB.histogramBoxPercentFromTop * 100.0,
     			cg.HB.centerX, cg.HB.centerY, cg.HB.leftOfBox, cg.HB.topOfBox, cg.HB.rightOfBox, cg.HB.bottomOfBox);
    -		printf("   New Exposure Algorithm: %s\n", yesNo(cg.HB.useExperimentalExposure));
    -		printf("   Video OFF Between Images: %s\n", yesNo(cg.videoOffBetweenImages));
    +		printf("   ZWO Exposure Type: %s\n", getZWOexposureType(cg.ZWOexposureType));
     	}
     	printf("   Preview: %s\n", yesNo(cg.preview));
    +	printf("   Focus mode: %s\n", yesNo(cg.determineFocus));
     	printf("   Taking Dark Frames: %s\n", yesNo(cg.takeDarkFrames));
     	printf("   Debug Level: %ld\n", cg.debugLevel);
     	printf("   On TTY: %s\n", yesNo(cg.tty));
    @@ -1289,7 +1285,6 @@ void displaySettings(config cg)
     		if (cg.supportsTemperature)
     			printf("      Show Temperature: %s, type: %s\n", yesNo(cg.overlay.showTemp), stringORnone(cg.tempType));
     		printf("      Show Gain: %s\n", yesNo(cg.overlay.showGain));
    -		printf("      Show Brightness: %s\n", yesNo(cg.overlay.showBrightness));
     		printf("      Show Target Mean Brightness: %s\n", yesNo(cg.overlay.showMean));
     		printf("      Show Focus Metric: %s\n", yesNo(cg.overlay.showFocus));
     		if (cg.ct == ctZWO) {
    @@ -1306,9 +1301,10 @@ void displaySettings(config cg)
     	printf("%s", c(KNRM));
     }
     
    -// Sleep when we're not taking daytime images.
    +// Sleep when we're not taking daytime or nighttime images.
     // Try to be smart about it so we don't sleep a gazillion times.
    -bool daytimeSleep(bool displayedMsg, config cg)
    +// "isDaytime" will be true if we're sleeping during the day, else at night.
    +bool day_night_timeSleep(bool displayedMsg, config cg, bool isDaytime)
     {
     	// Only display messages once a day.
     	if (! displayedMsg)
    @@ -1316,24 +1312,27 @@ bool daytimeSleep(bool displayedMsg, config cg)
     		if (cg.notificationImages) {
     			// In case another notification image is being upload, give it time to finish.
     			sleep(5);
    -			(void) displayNotificationImage("--expires 0 CameraOffDuringDay &");
    +			if (isDaytime)
    +				(void) displayNotificationImage("--expires 0 CameraOffDuringDay &");
    +			else
    +				(void) displayNotificationImage("--expires 0 CameraOffDuringNight &");
     		}
    -		Log(1, "It's daytime... we're not saving images.\n");
    +		Log(1, "It's %stime... we're not saving images.\n", isDaytime ? "day" : "night");
     		displayedMsg = true;
     
    -		// Sleep until a little before nighttime, then wake up and sleep more if needed.
    -		int secsTillNight = calculateTimeToNightTime(cg.latitude, cg.longitude, cg.angle);
    +		// Sleep until a little before nighttime/daytime, then wake up and sleep more if needed.
    +		int secsTillNext = calculateTimeToNextTime(cg.latitude, cg.longitude, cg.angle, isDaytime);
     		timeval t;
     		t = getTimeval();
    -		t.tv_sec += secsTillNight;
    -		Log(2, "Sleeping until %s (%'d seconds)\n", formatTime(t, cg.timeFormat), secsTillNight);
    -		sleep(secsTillNight);
    +		t.tv_sec += secsTillNext;
    +		Log(2, "Sleeping until %s (%'d seconds)\n", formatTime(t, cg.timeFormat), secsTillNext);
    +		sleep(secsTillNext);
     	}
     	else
     	{
     		// Shouldn't need to sleep more than a few times before nighttime.
     		int s = 5;
    -		Log(2, "Not quite nighttime; sleeping %'d more seconds\n", s);
    +		Log(2, "Not quite %time; sleeping %'d more seconds\n", isDaytime ? "night" : "day", s);
     		sleep(s);
     	}
     
    @@ -1377,9 +1376,17 @@ void delayBetweenImages(config cg, long lastExposure_us, std::string sleepType)
     // NULL-out CR or LF, or both if they are in a row.
     // Keep track of the beginning of the next line.
     // Return a pointer to the beginning of the line or NULL if at the end of the file.
    +
    +// Calling getLine(NULL) resets the pointer so the next call
    +// starts at the beginning of the buffer
     char *getLine(char *buffer)
     {
     	static char *nextLine = NULL;
    +	if (buffer == NULL)
    +	{
    +		nextLine = NULL;
    +		return(NULL);
    +	}
     	char *startOfLine;
     	char *ptr;
     
    @@ -1411,57 +1418,64 @@ char *getLine(char *buffer)
     	return(startOfLine);
     }
     
    -// Get settings from a configuration file.
    -bool called_from_getConfigFileArguments = false;
    -static bool getConfigFileArguments(config *cg)
    +// Read the specified file into the specified buffer.
    +char * readFileIntoBuffer(config *cg, const char *file)
     {
    -	if (called_from_getConfigFileArguments)
    -	{
    -		Log(-1, "*** %s: WARNING: Configuration file calls itself; ignoring!\n", cg->ME);
    -		return true;
    -	}
    -
    -	if (cg->configFile[0] == '\0') {
    -		Log(0, "*** %s: ERROR: Unable to read configuration file: no file specified!\n", cg->ME);
    -		return false;
    -	}
    -
    -	// Read the whole configuration file into memory so we can create argv with pointers
    -	static char *buf = NULL;
     	int fd;
    -	if ((fd = open(cg->configFile, O_RDONLY)) == -1)
    +	if ((fd = open(file, O_RDONLY)) == -1)
     	{
     		int e = errno;
    -		Log(0, "*** %s: ERROR: Could not open configuration file '%s': %s!",
    -			cg->ME, cg->configFile, strerror(e));
    -		return false;
    +		Log(0, "*** %s: ERROR: Could not open file '%s': %s!", cg->ME, file, strerror(e));
    +		return NULL;
     	}
     	struct stat statbuf;
     	if (fstat(fd, &statbuf) == 1)		// This should never fail
     	{
     		int e = errno;
    -		Log(0, "*** %s: ERROR: Could not fstat() configuration file '%s': %s!",
    -			cg->ME, cg->configFile, strerror(e));
    -		return false;
    +		Log(0, "*** %s: ERROR: Could not fstat() file '%s': %s!", cg->ME, file, strerror(e));
    +		return NULL;
     	}
     	// + 1 for trailing NULL
    +	char *buf = NULL;
     	if ((buf = (char *) realloc(buf, statbuf.st_size + 1)) == NULL)
     	{
     		int e = errno;
    -		Log(0, "*** %s: ERROR: Could not malloc() configuration file '%s': %s!",
    -			cg->ME, cg->configFile, strerror(e));
    -		return false;
    +		Log(0, "*** %s: ERROR: Could not realloc() file '%s': %s!", cg->ME, file, strerror(e));
    +		return NULL;
     	}
     	if (read(fd, buf, statbuf.st_size) != statbuf.st_size)
     	{
     		int e = errno;
    -		Log(0, "*** %s: ERROR: Could not read() configuration file '%s': %s!",
    -			cg->ME, cg->configFile, strerror(e));
    -		return false;
    +		Log(0, "*** %s: ERROR: Could not read() file '%s': %s!", cg->ME, file, strerror(e));
    +		return NULL;
     	}
    +
     	buf[statbuf.st_size] = '\0';
     	(void) close(fd);
     
    +	return(buf);
    +}
    +
    +// Get settings from a configuration file.
    +bool called_from_getConfigFileArguments = false;
    +bool getConfigFileArguments(config *cg)
    +{
    +	if (called_from_getConfigFileArguments)
    +	{
    +		Log(-1, "*** %s: WARNING: Configuration file calls itself; ignoring!\n", cg->ME);
    +		return true;
    +	}
    +
    +	if (cg->configFile[0] == '\0') {
    +		Log(0, "*** %s: ERROR: Unable to read configuration file: no file specified!\n", cg->ME);
    +		return false;
    +	}
    +
    +	// Read the whole configuration file into memory so we can create argv with pointers.
    +	static char *buf = readFileIntoBuffer(cg, cg->configFile);
    +	if (buf == NULL)
    +		return(false);
    +
     	int const numSettings = 500 * 2;	// some settings take an argument
     	char *argv[numSettings];
     
    @@ -1470,6 +1484,7 @@ static bool getConfigFileArguments(config *cg)
     
     	argv[argc++] = (char *) "getConfigFileArguments()";
     	char *line;
    +	(void) getLine(NULL);		// resets the buffer pointer
     	while ((line = getLine(buf)) != NULL)
     	{
     		lineNum++;
    @@ -1492,7 +1507,6 @@ static bool getConfigFileArguments(config *cg)
     			equal++;
     		}
     		// "equal" is pointing at equal sign or end of argumment
    -// TODO: if line doesn't start with "-", add it
     		argv[argc++] = line;
     		if (*equal == '=')
     		{
    @@ -1509,14 +1523,14 @@ static bool getConfigFileArguments(config *cg)
     
     	// Let's hope the config file doesn't call itself!
     	called_from_getConfigFileArguments = true;
    -	bool ret = getCommandLineArguments(cg, argc, argv);
    +	bool ret = getCommandLineArguments(cg, argc, argv, false);
     	called_from_getConfigFileArguments = false;
     	return(ret);
     }
     
     
     // Get arguments from the command line.
    -bool getCommandLineArguments(config *cg, int argc, char *argv[])
    +bool getCommandLineArguments(config *cg, int argc, char *argv[], bool readConfigFile)
     {
     	const char *b;
     	if (called_from_getConfigFileArguments)
    @@ -1529,12 +1543,7 @@ bool getCommandLineArguments(config *cg, int argc, char *argv[])
     
     	for (int i=1; i <= argc - 1; i++)
     	{
    -		// Allow UPPER and lower case on the command line.
    -		// Note that all strings in strcmp() must be lowercase.
     		char *a = argv[i];
    -		for (int j=0; a[j] != '\0'; j++) {
    -			a[j] = (char) tolower(a[j]);
    -		}
     		if (*a == '-') a++;		// skip leading "-"
     
     		Log(4, "%s >>> Parameter [%-*s]  Value: [%s]\n", b, n, a, argv[i+1]);
    @@ -1546,12 +1555,14 @@ bool getCommandLineArguments(config *cg, int argc, char *argv[])
     			// any command-line arguments after it will overwrite the config file.
     			// A file name of "[none]" means to ignore the option.
     			cg->configFile = argv[++i];
    -			if (strcmp(cg->configFile, "[none]") != 0 && ! getConfigFileArguments(cg))
    +			if (readConfigFile &&
    +				strcmp(cg->configFile, "[none]") != 0 &&
    +				! getConfigFileArguments(cg))
     			{
     				return(false);
     			}
     		}
    -		else if (strcmp(a, "h") == 0 || strcmp(a, "-help") == 0)
    +		else if (strcmp(a, "-help") == 0)
     		{
     			cg->help = true;
     			cg->quietExit = true;	// we display the help message and quit
    @@ -1560,6 +1571,10 @@ bool getCommandLineArguments(config *cg, int argc, char *argv[])
     		{
     			cg->version = argv[++i];
     		}
    +		else if (strcmp(a, "cameramodel") == 0)
    +		{
    +			cg->cm = argv[++i];
    +		}
     		else if (strcmp(a, "cameranumber") == 0)
     		{
     			cg->cameraNumber = atoi(argv[++i]);
    @@ -1579,7 +1594,21 @@ bool getCommandLineArguments(config *cg, int argc, char *argv[])
     		else if (strcmp(a, "cmd") == 0)
     		{
     			cg->cmdToUse = argv[++i];
    -			cg->isLibcamera = strcmp(cg->cmdToUse, "libcamera-still") == 0 ? true : false;
    +			if (cg->cmdToUse[0] == '\0')
    +			{
    +				cg->cmdToUse = NULL;		// usually with ZWO, which doesn't use this
    +			}
    +			else
    +			{
    +				if (strcmp(cg->cmdToUse, "raspistill") == 0)
    +				{
    +					cg->isLibcamera = false;
    +				}
    +				else
    +				{
    +					cg->isLibcamera = true;
    +				}
    +			}
     		}
     		else if (strcmp(a, "tty") == 0)	// overrides what was automatically determined
     		{
    @@ -1619,10 +1648,6 @@ bool getCommandLineArguments(config *cg, int argc, char *argv[])
     		{
     			cg->myModeMeanSetting.dayMean_threshold = atof(argv[++i]);
     		}
    -		else if (strcmp(a, "daybrightness") == 0)
    -		{
    -			cg->dayBrightness = atol(argv[++i]);
    -		}
     		else if (strcmp(a, "daydelay") == 0)
     		{
     			cg->dayDelay_ms = atol(argv[++i]);
    @@ -1673,6 +1698,14 @@ bool getCommandLineArguments(config *cg, int argc, char *argv[])
     		}
     
     		// nighttime settings
    +		else if (strcmp(a, "takenighttimeimages") == 0)
    +		{
    +			cg->nighttimeCapture = getBoolean(argv[++i]);
    +		}
    +		else if (strcmp(a, "savenighttimeimages") == 0)
    +		{
    +			cg->nighttimeSave = getBoolean(argv[++i]);
    +		}
     		else if (strcmp(a, "nightautoexposure") == 0)
     		{
     			cg->nightAutoExposure = getBoolean(argv[++i]);
    @@ -1693,10 +1726,6 @@ bool getCommandLineArguments(config *cg, int argc, char *argv[])
     		{
     			cg->myModeMeanSetting.nightMean_threshold = atof(argv[++i]);
     		}
    -		else if (strcmp(a, "nightbrightness") == 0)
    -		{
    -			cg->nightBrightness = atol(argv[++i]);
    -		}
     		else if (strcmp(a, "nightdelay") == 0)
     		{
     			cg->nightDelay_ms = atol(argv[++i]);
    @@ -1763,10 +1792,6 @@ bool getCommandLineArguments(config *cg, int argc, char *argv[])
     		{
     			cg->gamma = atol(argv[++i]);
     		}
    -		else if (strcmp(a, "offset") == 0)
    -		{
    -			cg->offset = atol(argv[++i]);
    -		}
     		else if (strcmp(a, "aggression") == 0)
     		{
     			cg->aggression = atol(argv[++i]);
    @@ -1823,6 +1848,10 @@ bool getCommandLineArguments(config *cg, int argc, char *argv[])
     		{
     			cg->flip = atol(argv[++i]);
     		}
    +		else if (strcmp(a, "determinefocus") == 0)
    +		{
    +			cg->determineFocus = getBoolean(argv[++i]);
    +		}
     		else if (strcmp(a, "notificationimages") == 0)
     		{
     			cg->notificationImages = getBoolean(argv[++i]);
    @@ -1859,13 +1888,9 @@ bool getCommandLineArguments(config *cg, int argc, char *argv[])
     		{
     			cg->debugLevel = atol(argv[++i]);
     		}
    -		else if (strcmp(a, "experimentalexposure") == 0)
    -		{
    -			cg->HB.useExperimentalExposure = getBoolean(argv[++i]);
    -		}
    -		else if (strcmp(a, "newexposure") == 0)
    +		else if (strcmp(a, "zwoexposuretype") == 0)
     		{
    -			cg->videoOffBetweenImages = getBoolean(argv[++i]);
    +			cg->ZWOexposureType = (ZWOexposure) atoi(argv[++i]);
     		}
     		else if (strcmp(a, "extraargs") == 0)
     		{
    @@ -1901,10 +1926,6 @@ bool getCommandLineArguments(config *cg, int argc, char *argv[])
     		{
     			cg->overlay.showGain = getBoolean(argv[++i]);
     		}
    -		else if (strcmp(a, "showbrightness") == 0)
    -		{
    -			cg->overlay.showBrightness = getBoolean(argv[++i]);
    -		}
     		else if (strcmp(a, "showmean") == 0)
     		{
     			cg->overlay.showMean = getBoolean(argv[++i]);
    @@ -1917,6 +1938,10 @@ bool getCommandLineArguments(config *cg, int argc, char *argv[])
     		{
     			cg->overlay.showFocus = getBoolean(argv[++i]);
     		}
    +		else if (strcmp(a, "showusb") == 0)
    +		{
    +			cg->overlay.showUSB = getBoolean(argv[++i]);
    +		}
     		else if (strcmp(a, "text") == 0)
     		{
     			cg->overlay.ImgText = argv[++i];
    @@ -1970,43 +1995,6 @@ bool getCommandLineArguments(config *cg, int argc, char *argv[])
     			cg->overlay.outlinefont = getBoolean(argv[++i]);
     		}
     
    -		// Arguments that may be passed to us but we don't use.
    -		else if (
    -			strcmp(a, "xx_end_xx") == 0 ||
    -			strcmp(a, "lastchanged") == 0 ||
    -			strcmp(a, "uselocalwebsite") == 0 ||
    -#define temp1 "useremote"
    -			strncmp(a, temp1, sizeof(temp1)-1) == 0 ||
    -#define temp2 "protocol"
    -			strncmp(a, temp2, sizeof(temp2)-1) == 0 ||
    -#define temp3 "imagedir"
    -			strncmp(a, temp3, sizeof(temp3)-1) == 0 ||
    -#define temp4 "videodestinationname"
    -			strncmp(a, temp4, sizeof(temp4)-1) == 0 ||
    -#define temp5 "keogramdeodestinationname"
    -			strncmp(a, temp5, sizeof(temp5)-1) == 0 ||
    -#define temp6 "startrailsdeodestinationname"
    -			strncmp(a, temp6, sizeof(temp6)-1) == 0 ||
    -			strcmp(a, "displaysettings") == 0 ||
    -			strcmp(a, "showonmap") == 0 ||
    -			strcmp(a, "websiteurl") == 0 ||
    -			strcmp(a, "imageurl") == 0 ||
    -			strcmp(a, "location") == 0 ||
    -			strcmp(a, "owner") == 0 ||
    -			strcmp(a, "camera") == 0 ||
    -			strcmp(a, "lens") == 0 ||
    -			strcmp(a, "computer") == 0 ||
    -			strcmp(a, "usedarkframes") == 0 ||
    -			strcmp(a, "uselogin") == 0 ||
    -			strcmp(a, "cameratype") == 0 ||
    -			strcmp(a, "cameramodel") == 0 ||
    -			strcmp(a, "showusb") == 0 ||
    -			strcmp(a, "alwaysshowadvanced") == 0
    -			)
    -		{
    -			i++;
    -		}
    -
     		else
     			Log(-1, "*** %s: WARNING: Unknown argument: [%s].  Ignored.\n", cg->ME, a);
     	}
    @@ -2016,7 +2004,8 @@ bool getCommandLineArguments(config *cg, int argc, char *argv[])
     	// producing Camera Capabilities info, in which case we need cg->CC_saveFile set so
     	// we know where to put the info.
     	// If we are in "help" mode then we won't take picture AND won't produce CC info.
    -	if (cg->saveDir == NULL && cg->CC_saveFile == NULL && ! cg->help) {
    +	if (cg->saveDir == NULL && cg->CC_saveFile == NULL &&
    +			! cg->help && called_from_getConfigFileArguments) {
     		cg->saveDir = cg->allskyHome;
     		Log(-1, "*** %s: WARNING: No directory to save images was specified. Using: [%s]\n",
     			cg->ME, cg->saveDir);
    @@ -2143,3 +2132,4 @@ void doLocale(config *cg)
     		Log(-1, "*** %s: WARNING: Could not set locale to %s.\n", cg->ME, cg->locale);
     	}
     }
    +
    diff --git a/src/capture_RPi.cpp b/src/capture_RPi.cpp
    index cf3fd000c..c36648b64 100644
    --- a/src/capture_RPi.cpp
    +++ b/src/capture_RPi.cpp
    @@ -40,21 +40,23 @@ using namespace std;
     timeval exposureStartDateTime;						// date/time an image started
     
     std::vector<int> compressionParameters;
    -bool bMain					= true;
    -bool bDisplay				= false;
    +bool bMain						= true;
    +bool bDisplay					= false;
     std::string dayOrNight;
    -int numErrors				= 0;					// Number of errors in a row
    -int maxErrors				= 4;					// Max number of errors in a row before we exit
    -
    -bool gotSignal				= false;				// did we get a SIGINT (from keyboard), or SIGTERM/SIGHUP (from service)?
    -int iNumOfCtrl				= NOT_SET;				// Number of camera control capabilities
    -pthread_t threadDisplay		= 0;					// Not used by Rpi;
    -int numExposures			= 0;					// how many valid pictures have we taken so far?
    -int currentBpp				= NOT_SET;				// bytes per pixel: 8, 16, or 24
    -int currentBitDepth			= NOT_SET;				// 8 or 16
    +int numTotalErrors				= 0;				// Total number of errors, fyi
    +int numConsecutiveErrors		= 0;				// Number of consecutive errors
    +int maxErrors					= 4;				// Max number of errors in a row before we exit
    +
    +bool gotSignal					= false;			// Did we get signal?
    +int iNumOfCtrl					= NOT_SET;			// Number of camera control capabilities
    +pthread_t threadDisplay			= 0;				// Not used by Rpi;
    +int numExposures				= 0;				// how many valid pictures have we taken so far?
    +int currentBpp					= NOT_SET;			// bytes per pixel: 8, 16, or 24
    +int currentBitDepth				= NOT_SET;			// 8 or 16
     raspistillSetting myRaspistillSetting;
     modeMeanSetting myModeMeanSetting;
    -std::string errorOutput			= "/tmp/capture_RPi_debug.txt";
    +std::string errorOutput;
    +
     
     //---------------------------------------------------------------------------------------------
     //---------------------------------------------------------------------------------------------
    @@ -81,20 +83,12 @@ int RPicapture(config cg, cv::Mat *image)
     	stringstream ss, ss2;
     
     	ss << cg.fullFilename;
    -	command += " --output '" + ss.str() + "'";
    -
    -	if (*cg.extraArgs)
    -	{
    -		// add the extra arguments as is; do not parse them
    -		ss.str("");
    -		ss << cg.extraArgs;
    -		command += " " + ss.str();
    -	}
    +	command += " --thumb none --output '" + ss.str() + "'";		// don't include a thumbnail in the file
     
     	if (cg.isLibcamera)
     	{
     		// libcamera tuning file
    -		if (cg.currentTuningFile != NULL && strcmp(cg.currentTuningFile, "") != 0) {
    +		if (cg.currentTuningFile != NULL && *cg.currentTuningFile != '\0') {
     			ss.str("");
     			ss << cg.currentTuningFile;
     			command += " --tuning-file '" + ss.str() + "'";
    @@ -105,10 +99,11 @@ int RPicapture(config cg, cv::Mat *image)
     	}
     	else
     	{
    -		command += " --thumb none --burst -st";
    +		command += " --burst -st";
     	}
     
     	// --timeout (in MS) determines how long the video will run before it takes a picture.
    +	// Value of 0 runs forever.
     	if (cg.preview)
     	{
     		stringstream wh;
    @@ -126,6 +121,8 @@ int RPicapture(config cg, cv::Mat *image)
     			if (myModeMeanSetting.meanAuto != MEAN_AUTO_OFF)
     			{
     				// We do our own auto-exposure so no need to wait at all.
    +// TODO: --immediate 0   works fine on Bookworm.
    +// If it also works on Bullseye then use it when we no longer support Buster.
     				// Tried --immediate, but on Buster (don't know about Bullseye), it hung exposures.
     				ss << 1;
     			}
    @@ -163,18 +160,13 @@ int RPicapture(config cg, cv::Mat *image)
     		//	'SRGGB10_CSI2P' : 1332x990 
     		//	'SRGGB12_CSI2P' : 2028x1080 2028x1520 4056x3040 
     		//								bin 2x2   bin 1x1
    -		if (cg.currentBin == 1)
    +		// cg.width and cg.height are already reduced for binning as needed.
    +		if (cg.currentBin == 1 || cg.currentBin == 2)
     		{
     			ss << cg.width;
     			ss2 << cg.height;
     			command += " --width " + ss.str() + " --height " + ss2.str();
     		}
    -		else if (cg.currentBin == 2)
    -		{
    -			ss << cg.width / 2;
    -			ss2 << cg.height / 2;
    -			command += " --width " + ss.str() + " --height " + ss2.str();
    -		}
     	}
     	else
     	{
    @@ -182,9 +174,10 @@ int RPicapture(config cg, cv::Mat *image)
     			command += " --mode 3";
     		else if (cg.currentBin == 2)
     		{
    -			ss << cg.width / 2;
    -			ss2 << cg.height / 2;
    -			command += " --mode 2 --width " + ss.str() + " --height " + ss2.str();
    +			command += " --mode 2";
    +//x			ss << cg.width / 2;
    +//x			ss2 << cg.height / 2;
    +//x			command += " --mode 2 --width " + ss.str() + " --height " + ss2.str();
     		}
     	}
     
    @@ -265,11 +258,10 @@ int RPicapture(config cg, cv::Mat *image)
     		if (! cg.isLibcamera)
     			command += " --awb off";		// raspistill requires explicitly turning off
     
    -		if (cg.currentWBR != cg.defaultWBR || cg.currentWBB != cg.defaultWBB) {
    -			ss.str("");
    -			ss << cg.currentWBR << "," << cg.currentWBB;
    -			command += " --awbgains " + ss.str();
    -		}
    +		// If we don't specify when they are the default then auto mode is enabled.
    +		ss.str("");
    +		ss << cg.currentWBR << "," << cg.currentWBB;
    +		command += " --awbgains " + ss.str();
     	}
     
     	if (cg.rotation != cg.defaultRotation) {
    @@ -303,22 +295,20 @@ int RPicapture(config cg, cv::Mat *image)
     		command += " --sharpness "+ ss.str();
     	}
     
    -	if (cg.currentBrightness != cg.defaultBrightness) {
    -		ss.str("");
    -		if (cg.isLibcamera)
    -			// User enters -100 to 100.  Convert to -1.0 to 1.0.
    -			ss << (float) cg.currentBrightness / 100;
    -		else
    -			ss << cg.currentBrightness;
    -		command += " --brightness " + ss.str();
    -	}
    -
     	if (cg.quality != cg.defaultQuality) {
     		ss.str("");
     		ss << cg.quality;
     		command += " --quality " + ss.str();
     	}
     
    +	if (*cg.extraArgs)
    +	{
    +		// add the extra arguments as is; do not parse them
    +		ss.str("");
    +		ss << cg.extraArgs;
    +		command += " " + ss.str();
    +	}
    +
     	// Log the command we're going to run without the
     	//		LIBCAMERA_LOG...
     	// string and without any redirect of stdout or stderr.
    @@ -332,7 +322,7 @@ int RPicapture(config cg, cv::Mat *image)
     	{
     		// If there have been 2 consecutive errors, chances are this one will fail too,
     		// so capture the error message.
    -		if (cg.debugLevel >= 3 || numErrors >= 2)
    +		if (cg.debugLevel >= 3 || numConsecutiveErrors >= 2)
     			s2 = " > " + errorOutput + " 2>&1";
     		else
     			s2 = " 2> /dev/null";	// gets rid of a bunch of libcamera verbose messages
    @@ -373,17 +363,19 @@ int RPicapture(config cg, cv::Mat *image)
     
     int main(int argc, char *argv[])
     {
    -	CG.ME = argv[0];
    +	CG.ME = basename(argv[0]);
     
    -	static char *a = getenv("ALLSKY_HOME");		// This must come before anything else
    -	if (a == NULL)
    +	CG.allskyHome = getenv("ALLSKY_HOME");
    +	if (CG.allskyHome == NULL)
     	{
     		Log(0, "*** %s: ERROR: ALLSKY_HOME not set!\n", CG.ME);
     		exit(EXIT_ERROR_STOP);
     	}
    -	else
    +
    +	if (! getCommandLineArguments(&CG, argc, argv, false))
     	{
    -		CG.allskyHome = a;
    +		// getCommandLineArguments outputs an error message.
    +		exit(EXIT_ERROR_STOP);
     	}
     
     	char bufTime[128]			= { 0 };
    @@ -391,17 +383,6 @@ int main(int argc, char *argv[])
     	char const *bayer[]			= { "RG", "BG", "GR", "GB" };
     	bool justTransitioned		= false;
     	ASI_ERROR_CODE asiRetCode;		// used for return code from ASI functions.
    -
    -	// We need to know its value before setting other variables.
    -	CG.cmdToUse = "libcamera-still";		// default
    -	if (argc > 2 && strcmp(argv[1], "-cmd") == 0 && strcmp(argv[2], CG.cmdToUse) == 0)
    -	{
    -		CG.isLibcamera = true;
    -	} else {
    -		CG.isLibcamera = false;
    -		CG.cmdToUse = "raspistill";
    -	}
    -
     	int retCode;
     	cv::Mat pRgb;							// the image
     
    @@ -432,9 +413,9 @@ int main(int argc, char *argv[])
     	if (! setDefaults(&CG, ASICameraInfo))
     		closeUp(EXIT_ERROR_STOP);
     
    -	if (! getCommandLineArguments(&CG, argc, argv))
    +	if (CG.configFile[0] != '\0' && ! getConfigFileArguments(&CG))
     	{
    -		// getCommandLineArguents outputs an error message.
    +		// getConfigFileArguments() outputs error messages
     		exit(EXIT_ERROR_STOP);
     	}
     
    @@ -460,6 +441,8 @@ int main(int argc, char *argv[])
     		closeUp(EXIT_ERROR_STOP);
     	}
     
    +	errorOutput = CG.saveDir;
    +	errorOutput += "/capture_RPi_debug.txt";
     
     	int iMaxWidth, iMaxHeight;
     	double pixelSize;
    @@ -537,8 +520,9 @@ int main(int argc, char *argv[])
     	int originalITextY		= CG.overlay.iTextY;
     	int originalFontsize	= CG.overlay.fontsize;
     	int originalLinewidth	= CG.overlay.linewidth;
    -	// Have we displayed "not taking picture during day" message, if applicable?
    +	// Have we displayed "not taking picture during day/night" messages, if applicable?
     	bool displayedNoDaytimeMsg = false;
    +	bool displayedNoNighttimeMsg = false;
     
     	// Start taking pictures
     
    @@ -551,13 +535,12 @@ int main(int argc, char *argv[])
     		if (CG.takeDarkFrames)
     		{
     			// We're doing dark frames so turn off autoexposure and autogain, and use
    -			// nightime gain, delay, exposure, and brightness to mimic a nightime shot.
    +			// nightime gain, delay, and exposure to mimic a nightime shot.
     			CG.currentSkipFrames = 0;
     			CG.currentAutoExposure = false;
     			CG.nightAutoExposure = false;
     			CG.currentExposure_us = CG.nightMaxAutoExposure_us;
     			CG.currentMaxAutoExposure_us = CG.nightMaxAutoExposure_us;
    -			CG.currentBrightness = CG.nightBrightness;
     			if (CG.isColorCamera)
     			{
     				CG.currentAutoAWB = false;
    @@ -601,7 +584,8 @@ int main(int argc, char *argv[])
     
     			if (! CG.daytimeCapture)
     			{
    -				displayedNoDaytimeMsg = daytimeSleep(displayedNoDaytimeMsg, CG);
    +				// true == for daytime
    +				displayedNoDaytimeMsg = day_night_timeSleep(displayedNoDaytimeMsg, CG, true);
     
     				// No need to do any of the code below so go back to the main loop.
     				continue;
    @@ -615,7 +599,6 @@ int main(int argc, char *argv[])
     			CG.currentAutoExposure = CG.dayAutoExposure;
     			CG.currentExposure_us = CG.dayExposure_us;
     			CG.currentMaxAutoExposure_us = CG.dayMaxAutoExposure_us;
    -			CG.currentBrightness = CG.dayBrightness;
     			if (CG.isColorCamera)
     			{
     				CG.currentAutoAWB = CG.dayAutoAWB;
    @@ -649,6 +632,15 @@ int main(int argc, char *argv[])
     				justTransitioned = false;
     			}
     
    +			if (! CG.nighttimeCapture)
    +			{
    +				// false == for nighttime
    +				displayedNoNighttimeMsg = day_night_timeSleep(displayedNoNighttimeMsg, CG, false);
    +
    +				// No need to do any of the code below so go back to the main loop.
    +				continue;
    +			}
    +
     			Log(1, "==========\n=== Starting nighttime capture ===\n==========\n");
     
     			// We only skip initial frames if we are starting in nighttime and using auto-exposure.
    @@ -658,7 +650,6 @@ int main(int argc, char *argv[])
     			CG.currentAutoExposure = CG.nightAutoExposure;
     			CG.currentExposure_us = CG.nightExposure_us;
     			CG.currentMaxAutoExposure_us = CG.nightMaxAutoExposure_us;
    -			CG.currentBrightness = CG.nightBrightness;
     			if (CG.isColorCamera)
     			{
     				CG.currentAutoAWB = CG.nightAutoAWB;
    @@ -717,12 +708,17 @@ myModeMeanSetting.modeMean = CG.myModeMeanSetting.modeMean;
     			CG.overlay.fontsize		= originalFontsize / CG.currentBin;
     			CG.overlay.linewidth	= originalLinewidth / CG.currentBin;
     
    -// TODO: if not the first time, should we free the old pRgb?
    +			if (numExposures > 0)
    +			{
    +				// If not the first time, free the prior pRgb.
    +				pRgb.release();
    +			}
    +
     			if (CG.imageType == IMG_RAW16)
     			{
     				pRgb.create(cv::Size(CG.width, CG.height), CV_16UC1);
     			}
    -				else if (CG.imageType == IMG_RGB24)
    +			else if (CG.imageType == IMG_RGB24)
     			{
     				pRgb.create(cv::Size(CG.width, CG.height), CV_8UC3);
     			}
    @@ -766,14 +762,14 @@ myModeMeanSetting.modeMean = CG.myModeMeanSetting.modeMean;
     			if (retCode == 0)
     			{
     				numExposures++;
    -				numErrors = 0;
    +				numConsecutiveErrors = 0;
     
     				// We currently have no way to get the actual white balance values,
     				// so use what the user requested.
     				CG.lastWBR = CG.currentWBR;
     				CG.lastWBB = CG.currentWBB;
     
    -				CG.lastFocusMetric = CG.overlay.showFocus ? (int)round(get_focus_metric(pRgb)) : -1;
    +				CG.lastFocusMetric = CG.determineFocus ? (int)round(get_focus_metric(pRgb)) : -1;
     
     				// If takeDarkFrames is off, add overlay text to the image
     				if (! CG.takeDarkFrames)
    @@ -789,7 +785,6 @@ myModeMeanSetting.modeMean = CG.myModeMeanSetting.modeMean;
     					}
     
     					CG.lastMean = aegCalcMean(pRgb, true);
    -					CG.lastMeanFull = aegCalcMean(pRgb, false);
     					if (myModeMeanSetting.meanAuto != MEAN_AUTO_OFF)
     					{
     						// set myRaspistillSetting.shutter_us and myRaspistillSetting.analoggain
    @@ -879,10 +874,11 @@ myModeMeanSetting.modeMean = CG.myModeMeanSetting.modeMean;
     						CG.ME, retCode, WEXITSTATUS(retCode));
     				}
     
    -				numErrors++;
    -				if (numErrors >= maxErrors)
    +				numTotalErrors++;
    +				numConsecutiveErrors++;
    +				if (numConsecutiveErrors >= maxErrors)
     				{
    -					Log(0, "*** %s: ERROR: maximum number of consecutive errors of %d reached; capture program stopped.\n", CG.ME, maxErrors);
    +					Log(0, "*** %s: ERROR: maximum number of consecutive errors of %d reached; capture program stopped. Total errors=%'d.\n", CG.ME, maxErrors, numTotalErrors);
     					Log(0, "Make sure cable between camera and Pi is all the way in.\n");
     					Log(0, "Look in '%s' for details.\n", errorOutput.c_str());
     					closeUp(EXIT_ERROR_STOP);
    diff --git a/src/capture_ZWO.cpp b/src/capture_ZWO.cpp
    index 2e0d38c5a..12cf71747 100644
    --- a/src/capture_ZWO.cpp
    +++ b/src/capture_ZWO.cpp
    @@ -25,8 +25,6 @@ config CG;
     #define IS_ZWO
     #include "ASI_functions.cpp"
     
    -bool useSnapshotMode = false;	// XXXXXX use the ZWO snapshot exposure mode or vide mode?
    -
     // Forward definitions
     char *getRetCode(ASI_ERROR_CODE);
     void closeUp(int);
    @@ -39,13 +37,7 @@ bool checkMaxErrors(int *, int);
     // These are global so they can be used by other routines.
     // Variables for command-line settings are first and are "long" so we can use validateLong().
     
    -// In version 0.8 we introduced a different way to take exposures. Instead of turning video mode on at
    -// the beginning of the program and off at the end (which kept the camera running all the time, heating it up),
    -// version 0.8 turned video mode on, then took a picture, then turned it off. This helps cool the camera,
    -// but some users (seems hit or miss) get ASI_ERROR_TIMEOUTs when taking exposures with the new method.
    -// So, we added the ability for them to use the 0.7 video-always-on method, or the 0.8 "new exposure" method.
     timeval exposureStartDateTime;									// date/time an image started
    -
     cv::Mat pRgb;
     std::vector<int> compressionParameters;
     bool bMain						= true;
    @@ -56,14 +48,16 @@ bool bSavingImg					= false;
     pthread_mutex_t mtxSaveImg;
     pthread_cond_t condStartSave;
     ASI_CONTROL_CAPS ControlCaps;
    -int numErrors					= 0;				// Number of errors in a row.
    +int numTotalErrors				= 0;				// Total number of errors, fyi
    +int numConsecutiveErrors		= 0;				// Number of consecutive errors
     int maxErrors					= 5;				// Max number of errors in a row before we exit
    -bool gotSignal					= false;			// did we get a SIGINT (from keyboard), or SIGTERM/SIGHUP (from service)?
    +bool gotSignal					= false;			// Did we get a signal?
     int iNumOfCtrl					= NOT_SET;			// Number of camera control capabilities
     pthread_t threadDisplay			= 0;
     pthread_t hthdSave				= 0;
     int numExposures				= 0;				// how many valid pictures have we taken so far?
     int currentBpp					= NOT_SET;			// bytes per pixel: 1, 2, or 3
    +bool capturingVideo				= false;			// are we capturing video?
     
     // Make sure we don't try to update a non-updateable control, and check for errors.
     ASI_ERROR_CODE setControl(int camNum, ASI_CONTROL_TYPE control, long value, ASI_BOOL makeAuto)
    @@ -140,7 +134,7 @@ void *Display(void *params)
     		// default preview size usually fills whole screen, so shrink.
     		cv::resize(*pImg, *pImg2, cv::Size((int)w/2, (int)h/2));
     		cv::imshow("Preview", *pImg2);
    -		cv::waitKey(500);	// TODO: wait for exposure time instead of hard-coding value
    +		cv::waitKey(500);
     	}
     	cv::destroyWindow("Preview");
     	Log(4, "Display thread over\n");
    @@ -161,7 +155,6 @@ void *SaveImgThd(void *para)
     			break;
     		}
     
    -		bSavingImg = true;
     
     		// I don't know how to cast "st" to 0, so call now() and ignore it.
     		auto st = std::chrono::high_resolution_clock::now();
    @@ -170,50 +163,53 @@ void *SaveImgThd(void *para)
     		bool result = false;
     		if (pRgb.data)
     		{
    +			bSavingImg = true;
    +
     			char cmd[1100+strlen(CG.allskyHome)];
    -			Log(4, "  > Saving %s image '%s'\n", CG.takeDarkFrames ? "dark" : dayOrNight.c_str(), CG.finalFileName);
    -			snprintf(cmd, sizeof(cmd), "%s/scripts/saveImage.sh %s '%s'", CG.allskyHome, dayOrNight.c_str(), CG.fullFilename);
    +			Log(4, "  > Saving %s image '%s'\n",
    +				CG.takeDarkFrames ? "dark" : dayOrNight.c_str(), CG.finalFileName);
    +			snprintf(cmd, sizeof(cmd), "%s/scripts/saveImage.sh %s '%s'",
    +				CG.allskyHome, dayOrNight.c_str(), CG.fullFilename);
     			add_variables_to_command(CG, cmd, exposureStartDateTime);
     			strcat(cmd, " &");
     
    -			st = std::chrono::high_resolution_clock::now();
     			try
     			{
    +				st = std::chrono::high_resolution_clock::now();
     				result = imwrite(CG.fullFilename, pRgb, compressionParameters);
    +				et = std::chrono::high_resolution_clock::now();
     			}
     			catch (const cv::Exception& ex)
     			{
     				Log(0, "*** %s: ERROR: Exception saving image: %s\n", CG.ME, ex.what());
     			}
    -			et = std::chrono::high_resolution_clock::now();
     
     			if (result)
    +			{
     				system(cmd);
    +
    +				static int totalSaves = 0;
    +				static double totalTime_ms = 0;
    +
    +				totalSaves++;
    +				long long diff_us = std::chrono::duration_cast<std::chrono::microseconds>(et - st).count();
    +				double diff_ms = diff_us / US_IN_MS;
    +				totalTime_ms += diff_ms;
    +
    +				Log(4, "  > Image took %'.1f ms to save (average %'.1f ms).\n",
    +					diff_ms, totalTime_ms / totalSaves);
    +			}
     			else
    +			{
     				Log(0, "*** %s: ERROR: Unable to save image '%s'.\n", CG.ME, CG.fullFilename);
    +			}
    +
    +			bSavingImg = false;
     
     		} else {
     			// This can happen if the program is closed before the first picture.
     			Log(0, "----- SaveImgThd(): pRgb.data is null\n");
     		}
    -		bSavingImg = false;
    -
    -		if (result)
    -		{
    -			static int totalSaves = 0;
    -			static double totalTime_ms = 0;
    -			totalSaves++;
    -// FIX: should be / ?
    -			long long diff_us = std::chrono::duration_cast<std::chrono::microseconds>(et - st).count();
    -			double diff_ms = diff_us / US_IN_MS;
    -			totalTime_ms += diff_ms;
    -			char const *x;
    -			if (diff_ms > 1 * MS_IN_SEC)
    -				x = "  > *****\n";	// indicate when it takes a REALLY long time to save
    -			else
    -				x = "";
    -			Log(4, "%s  > Image took %'.1f ms to save (average %'.1f ms).\n%s", x, diff_ms, totalTime_ms / totalSaves, x);
    -		}
     
     		pthread_mutex_unlock(&mtxSaveImg);
     	}
    @@ -231,7 +227,7 @@ void *SaveImgThd(void *para)
     // eg. box size 0x0, box size WxW, box crosses image edge, ... basically
     // anything that would read/write out-of-bounds
     
    -int computeHistogram(unsigned char *imageBuffer, config cg, bool useHistogramBox)
    +double computeHistogram(unsigned char *imageBuffer, config cg, bool useHistogramBox)
     {
     	unsigned char *buf = imageBuffer;
     	const int histogramEntries = 256;
    @@ -265,8 +261,6 @@ int computeHistogram(unsigned char *imageBuffer, config cg, bool useHistogramBox
     	// For RGB24, data for each pixel is stored in 3 consecutive bytes: blue, green, red.
     	// For all image types, each row in the image contains one row of pixels.
     	// currentBpp doesn't apply to rows, just columns.
    -//x int on = 0;
    -//x static int did = 0; did++;
     	switch (cg.imageType) {
     	case IMG_RGB24:
     	case IMG_RAW8:
    @@ -281,7 +275,6 @@ int computeHistogram(unsigned char *imageBuffer, config cg, bool useHistogramBox
     					avg += buf[i+1] + buf[i+2];
     					avg /= currentBpp;
     				}
    -//x if (useHistogramBox && did <=5) { printf("avg[%d]=%d\n", ++on, avg); }
     				histogram[avg]++;
     			}
     		}
    @@ -294,7 +287,6 @@ int computeHistogram(unsigned char *imageBuffer, config cg, bool useHistogramBox
     				// Use the least significant byte.
     				// This assumes the image data is laid out in big endian format.
     				pixelValue = buf[i+1];
    -//x if (useHistogramBox && did <=5) { printf("pixel[%d]=0x%02x%02x, pixelValue=%'d\n", ++on, buf[i], buf[i+1], pixelValue); }
     				histogram[pixelValue]++;
     			}
     		}
    @@ -304,12 +296,10 @@ int computeHistogram(unsigned char *imageBuffer, config cg, bool useHistogramBox
     	}
     
     	// Now calculate the mean.
    -	int meanBin = 0;
     	int a = 0, b = 0;
     	for (int i = 0; i < histogramEntries; i++) {
     		a += (i+1) * histogram[i];
     		b += histogram[i];
    -//x if (useHistogramBox && histogram[i] > 0 && did <=5) { printf("histogram[%d]=%'d, a=%'d, b=%'d\n", i, histogram[i], a, b); }
     	}
     
     	if (b == 0)
    @@ -318,8 +308,8 @@ int computeHistogram(unsigned char *imageBuffer, config cg, bool useHistogramBox
     		return(0);
     	}
     
    -	meanBin = a/b - 1;
    -	return meanBin;
    +	// Need to normalize from 0.0 to 1.0.
    +	return ((a/b - 1) / (double)histogramEntries);
     }
     
     // This is based on code from PHD2.
    @@ -340,26 +330,33 @@ ASI_ERROR_CODE flushBufferedImages(config *cg, unsigned char *buf, long size)
     
     	for (int i = 0; i < NUM_IMAGE_BUFFERS; i++)
     	{
    -		status = ASIGetVideoData(cg->cameraNumber, buf, size, 10);
    +		status = ASIGetVideoData(cg->cameraNumber, buf, size, 500 + (2 * cg->cameraMinExposure_us));
     		if (status == ASI_SUCCESS)
     		{
     			Log(3, "  > [Cleared buffer frame]: %s\n", getRetCode(status));
     		}
    -		else if (status != ASI_ERROR_TIMEOUT)
    +		else if (status == ASI_ERROR_TIMEOUT)
     		{
    -			Log(0, "*** %s: ERROR: flushBufferedImages() got %s\n", cg->ME, getRetCode(status));
    +			// No more frames left in buffer.
    +			return(status);
     		}
     		else
     		{
    -			// ASI_ERROR_TIMEOUT.  No more left.
    -			return(status);
    +			Log(1, "%s: WARNING: flushBufferedImages() got %s\n", cg->ME, getRetCode(status));
     		}
     	}
     
    -	return(ASI_SUCCESS);
    +	return(status);
     }
     
     
    +// In version 0.8 we introduced a different way to take exposures. Instead of turning video mode on at
    +// the beginning of the program and off at the end (which kept the camera running all the time, heating it up),
    +// version 0.8 turned video mode on, then took a picture, then turned it off. This helps cool the camera,
    +// but some users (seems hit or miss) get ASI_ERROR_TIMEOUTs when taking exposures with the new method.
    +// So, we added the ability for them to use the 0.7 video-always-on method, or the 0.8 "new exposure" method.
    +// In the 2024 version we added "snapshot" mode to overcome the ASI_ERROR_TIMEOUTs.
    +
     // Next exposure suggested by the camera.
     long suggestedNextExposure_us = 0;
     
    @@ -382,188 +379,270 @@ ASI_ERROR_CODE takeOneExposure(config *cg, unsigned char *imageBuffer)
     	// USB contention, such as that caused by heavy USB disk IO
     	long timeout = ((cg->currentExposure_us * 2) / US_IN_MS) + 5000;	// timeout is in ms
     
    -	// This debug message isn't typcally needed since we already displayed a message about
    -	// starting a new exposure, and below we display the result when the exposure is done.
    -	Log(3, "    > %s to %s\n",
    -		cg->HB.useHistogram ? "Histogram set exposure" :
    -			(wasAutoExposure == ASI_TRUE ? "Camera set auto-exposure" : "Manual exposure set"),
    -		length_in_units(cg->currentExposure_us, true));
    +	// Sanity check.
    +	if (cg->HB.useHistogram && cg->currentAutoExposure)
    +		Log(0, "  > %s: ERROR: HB.useHistogram AND currentAutoExposure are both set\n", cg->ME);
     
    -	if (! useSnapshotMode)
    +	if (cg->ZWOexposureType != ZWOsnap)
     		flushBufferedImages(cg, imageBuffer, bufferSize);
     
    -	// Sanity check.
    -	if (cg->HB.useHistogram && cg->currentAutoExposure == ASI_TRUE)
    -		Log(0, "*** %s: ERROR: HB.useHistogram AND currentAutoExposure are both set\n", cg->ME);
    -
     	setControl(cg->cameraNumber, ASI_EXPOSURE, cg->currentExposure_us, cg->currentAutoExposure ? ASI_TRUE : ASI_FALSE);
     
    -	if (! useSnapshotMode && cg->videoOffBetweenImages)
    +	if (cg->ZWOexposureType == ZWOvideoOff && ! capturingVideo)
     	{
     		status = ASIStartVideoCapture(cg->cameraNumber);
    -	} else {
    -		status = ASI_SUCCESS;
    +		if (status != ASI_SUCCESS) {
    +			Log(0, "  > %s: ERROR: ASIStartVideoCapture() failed: %s.\n", cg->ME, getRetCode(status));
    +			Log(1, "  > Total errors=%'d\n", numTotalErrors+1);
    +			return(status);
    +		}
    +		capturingVideo = true;
     	}
     
    -	if (status == ASI_SUCCESS) {
    -		// Make sure the actual time to take the picture is "close" to the requested time.
    -		auto tStart = std::chrono::high_resolution_clock::now();
    +	// Make sure the actual time to take the picture is "close" to the requested time.
    +	auto tStart = std::chrono::high_resolution_clock::now();
    +	int exitCode;
     
    -		if (useSnapshotMode)
    +	if (cg->ZWOexposureType == ZWOsnap)
    +	{
    +		status = ASIStartExposure(cg->cameraNumber, ASI_FALSE);
    +		if (status != ASI_SUCCESS)
     		{
    -// xxxxxxxxxxxxxxxxxx start exposure, sleep for 95% of cg->currentExposure_us, then check every 5 us.
    -		} else {
    -			status = ASIGetVideoData(cg->cameraNumber, imageBuffer, bufferSize, timeout);
    -			if (cg->videoOffBetweenImages)
    +			Log(0, "  > %s: ERROR: ASIStartExposure() failed: %s.\n", cg->ME, getRetCode(status));
    +			Log(1, "  > Total errors=%'d\n", numTotalErrors+1);
    +			if (! checkMaxErrors(&exitCode, maxErrors))
    +				closeUp(exitCode);
    +			return(status);
    +		}
    +
    +		// Do an initial sleep of the exposure time + 500 ms for overhead,
    +		// then go into a check_status / sleep loop where
    +		// we sleep for 5% of the exposure time.
    +		// The total sleep time will be longer than exposure time due to overhead starting the exposure.
    +		long initial_sleep_us = cg->currentExposure_us + 500 * US_IN_MS;
    +		long sleep_us = std::max(cg->currentExposure_us * 0.05, 1.0);
    +		Log(4, "      > Doing initial usleep(%'ld) for exposure time %'ld.\n", initial_sleep_us, cg->currentExposure_us);
    +		usleep(initial_sleep_us);
    +
    +		// We should be fairly close to the end of the exposure so now go
    +		// into a loop until the exposure is done.
    +		ASI_EXPOSURE_STATUS s = ASI_EXP_WORKING;
    +		int num_sleeps = 0;
    +		while (s == ASI_EXP_WORKING)
    +		{
    +			status = ASIGetExpStatus(cg->cameraNumber, &s);
    +			if (status != ASI_SUCCESS)
     			{
    -				ret = ASIStopVideoCapture(cg->cameraNumber);
    -				if (ret != ASI_SUCCESS)
    -				{
    -					Log(1, "  > %s: WARNING: ASIStopVideoCapture() failed: %s\n", cg->ME, getRetCode(ret));
    -				}
    +				Log(0, "  > %s: ERROR: ASIGetExpStatus() failed after %d sleeps: %s.\n",
    +					cg->ME, num_sleeps, getRetCode(status));
    +				Log(1, "  > Total errors=%'d\n", numTotalErrors+1);
    +				if (! checkMaxErrors(&exitCode, maxErrors))
    +					closeUp(exitCode);
    +				return(status);
     			}
    +			usleep(sleep_us);
    +			num_sleeps++;
     		}
    +		Log(4, "      > Did usleep(%'ld) %d times in loop for total usleep() of %'ldus\n",
    +			sleep_us, num_sleeps, initial_sleep_us + (sleep_us * num_sleeps));
     
    +		// Exposure done, if it worked get the image
    +		if (s != ASI_EXP_SUCCESS)
    +		{
    +			// This error DOES happen sometimes.
    +			// Unfortunately "s" is either success or failure - not much help.
    +			Log(1, "    > ERROR: Exposure failed after %d sleeps, s=%d.\n", num_sleeps, s);
    +			Log(1, "  > Total errors=%'d\n", numTotalErrors+1);
    +			if (! checkMaxErrors(&exitCode, maxErrors))
    +				closeUp(exitCode);
    +			return(ASI_ERROR_END);
    +		}
    +
    +		status = ASIGetDataAfterExp(cg->cameraNumber,  imageBuffer, bufferSize);
     		if (status != ASI_SUCCESS)
     		{
    -			int exitCode;
    -			Log(0, "  > %s: ERROR: Failed getting image: %s\n", cg->ME, getRetCode(status));
    +			// For whatever reason this does fail sometimes, so to avoid having
    +			// every failure appear in the WebUI message center, log with level 1.
    +			Log(1, "  > ERROR: ASIGetDataAfterExp() failed after %d sleeps: %s.\n",
    +				num_sleeps, getRetCode(status));
    +			Log(1, "  > Total errors=%'d\n", numTotalErrors+1);
    +			if (! checkMaxErrors(&exitCode, maxErrors))
    +				closeUp(exitCode);
    +			return(status);
    +		}
     
    +	} else {	// some video mode
    +		status = ASIGetVideoData(cg->cameraNumber, imageBuffer, bufferSize, timeout);
    +		if (status != ASI_SUCCESS)
    +		{
    +			Log(0, "  > %s: ERROR: Failed getting image: %s.\n",
    +				cg->ME, getRetCode(status));
    +			Log(1, "  > Total errors=%'d\n", numTotalErrors+1);
    +	
     			// Check if we reached the maximum number of consective errors
     			if (! checkMaxErrors(&exitCode, maxErrors))
    -			{
     				closeUp(exitCode);
    -			}
    +			return(status);
     		}
    -		else
    +		if (cg->ZWOexposureType == ZWOvideoOff)
     		{
    -			// The timeToTakeImage_us should never be less than what was requested.
    -			// and shouldn't be less then the time taked plus overhead of setting up the shot.
    -
    -			auto tElapsed = std::chrono::high_resolution_clock::now() - tStart;
    -			long timeToTakeImage_us = std::chrono::duration_cast<std::chrono::microseconds>(tElapsed).count();
    -			long diff_us = timeToTakeImage_us - cg->currentExposure_us;
    -			long threshold_us = 0;
    -
    -			bool tooShort = false;
    -			if (diff_us < 0)
    -			{
    -				tooShort = true;			// WAY too short
    -			}
    -			else if (cg->currentExposure_us > (5 * US_IN_SEC))
    +			ret = ASIStopVideoCapture(cg->cameraNumber);
    +			if (ret != ASI_SUCCESS)
     			{
    -				// There is too much variance in the overhead of taking pictures to
    -				// accurately determine the actual time to take an image at short exposures,
    -				// so only check for long ones.
    -				// Testing shows there's about this much us overhead,
    -				// so subtract it to get our best estimate of the "actual" time.
    -				const int OVERHEAD_us = (int) (0.34 * US_IN_SEC);
    -
    -				// Don't subtract if it would have made timeToTakeImage_us negative.
    -				if (timeToTakeImage_us > OVERHEAD_us)
    -					diff_us -= OVERHEAD_us;
    -
    -				threshold_us = cg->currentExposure_us * 0.5;	// 50% seems like a good number
    -				if (abs(diff_us) > threshold_us)
    -					tooShort = true;
    +				Log(1, "  > WARNING: ASIStopVideoCapture() failed: %s\n", getRetCode(ret));
    +				// continue
     			}
    +			capturingVideo = false;
    +		}
    +	}
     
    -			if (tooShort)
    -			{
    -				Log(1, "   *** WARNING: Time to take exposure (%s) ",
    -					length_in_units(timeToTakeImage_us, true));
    -				Log(1, "differs from requested exposure time (%s) by %s, threshold=%s\n",
    -					length_in_units(cg->currentExposure_us, true),
    -					length_in_units(diff_us, true),
    -					length_in_units(threshold_us, true));
    -			}
    -			else
    -			{
    -// XXXXXXXXXXXXX set to 4 after testing
    -				Log(3, "    > Time to take exposure=%'ld us, diff_us=%'ld", timeToTakeImage_us, diff_us);
    -				if (threshold_us > 0)
    -					Log(3, ", threshold_us=%'ld", threshold_us);
    -				Log(3, "\n");
    -			}
    +	// We successfully got the image so reset the global error counter;
    +	numConsecutiveErrors = 0;
     
    -			numErrors = 0;
    -			long l;
    -			ret = ASIGetControlValue(cg->cameraNumber, ASI_GAIN, &l, &bAuto);
    -			if (ret != ASI_SUCCESS)
    -			{
    -				Log(1, "  > %s: WARNING: ASIGetControlValue(ASI_GAIN) failed: %s\n", cg->ME, getRetCode(ret));
    -			}
    -			cg->lastGain = (double) l;
    +	// The timeToTakeImage_us should never be less than what was requested.
    +	// and shouldn't be less then the time taken plus overhead of setting up the shot.
     
    -			char tempBuf[500];
    -			tempBuf[0] = '\0';
    -			char *tb = tempBuf;
    +	auto tElapsed = std::chrono::high_resolution_clock::now() - tStart;
    +	long timeToTakeImage_us = std::chrono::duration_cast<std::chrono::microseconds>(tElapsed).count();
    +	long diff_us = timeToTakeImage_us - cg->currentExposure_us;
    +	long threshold_us = 0;
     
    -			cg->lastMean = (double)computeHistogram(imageBuffer, *cg, true);
    +	bool tooShort = false;
    +	if (diff_us < 0)
    +	{
    +		// This "should" never happen but actually does sometimes.
    +		tooShort = true;
    +	}
    +	else if (cg->currentExposure_us > (5 * US_IN_SEC))
    +	{
    +		// There is too much variance in the overhead of taking pictures to
    +		// accurately determine the actual time to take an image at short exposures,
    +		// so only check for long ones.
    +		// Testing shows there's about this much us overhead (at least for video mode),
    +		// so subtract it to get our best estimate of the "actual" time.
    +		const int OVERHEAD_us = (int) (340 * US_IN_MS);
    +
    +		// Don't subtract if it would have made timeToTakeImage_us negative.
    +		if (timeToTakeImage_us > OVERHEAD_us)
    +			diff_us -= OVERHEAD_us;
    +
    +		float t;
    +		// These seem like good numbers.
    +		// snapshot mode seems more consistent so use a lower threshold.
    +		if (cg->ZWOexposureType == ZWOsnap)
    +			t = 0.2;
    +		else
    +			t = 0.5;
    +		threshold_us = cg->currentExposure_us * t;
    +		if (abs(diff_us) > threshold_us)
    +			tooShort = true;
    +	}
     
    -// xxxxxx for testing.  Get the mean of the whole image so we can compare to what removeBadImages.sh calculates.
    -//	If it's the same, then the algorithms are the same and removeBadImages.sh can use MEAN.
    -cg->lastMeanFull = (double)computeHistogram(imageBuffer, *cg, false);
    +	if (tooShort)
    +	{
    +		Log(1, "   *** WARNING: Time to take exposure (%s) ",
    +			length_in_units(timeToTakeImage_us, true));
    +		Log(1, "differs from requested exposure time (%s) by %s, threshold=%s\n",
    +			length_in_units(cg->currentExposure_us, true),
    +			length_in_units(diff_us, true),
    +			length_in_units(threshold_us, true));
    +	}
    +	else
    +	{
    +		Log(4, "      > Time to take exposure=%'ld us, diff_us=%'ld", timeToTakeImage_us, diff_us);
    +		if (threshold_us > 0)
    +			Log(4, ", threshold_us=%'ld", threshold_us);
    +		Log(4, "\n");
    +	}
     
    -			sprintf(tb, " @ mean %d, %sgain %ld, fullMean %d",
    -				(int) cg->lastMean, cg->currentAutoGain ? "(auto) " : "",
    -				(long) cg->lastGain, (int) cg->lastMeanFull);
    -			cg->lastExposure_us = cg->currentExposure_us;
    +	// Get some metadata on the image.
     
    -			// Per ZWO, when in manual-exposure mode, the returned exposure length should always
    -			// be equal to the requested length; in fact, "there's no need to call ASIGetControlValue()".
    -			// When in auto-exposure mode, the returned exposure length is what the driver thinks the
    -			// next exposure should be, and will eventually converge on the correct exposure.
    -			ret = ASIGetControlValue(cg->cameraNumber, ASI_EXPOSURE, &suggestedNextExposure_us, &wasAutoExposure);
    -			if (ret != ASI_SUCCESS)
    -			{
    -				Log(1, "  > WARNING: ASIGetControlValue(ASI_EXPOSURE) failed: %s\n", cg->ME, getRetCode(ret));
    -			}
    -			Log(2, "  > GOT IMAGE%s.", tb);
    -			Log(3, cg->HB.useHistogram ? " Ignoring suggested next exposure of %s." : "  Suggested next exposure: %s.",
    -				length_in_units(suggestedNextExposure_us, true));
    -			Log(2, "\n");
    +	long l;
    +	ret = ASIGetControlValue(cg->cameraNumber, ASI_GAIN, &l, &bAuto);
    +	if (ret != ASI_SUCCESS)
    +	{
    +		Log(1, "  > %s: WARNING: ASIGetControlValue(ASI_GAIN) failed: %s\n", cg->ME, getRetCode(ret));
    +	}
    +	else
    +	{
    +		cg->lastGain = (double) l;
    +	}
     
    -			long temp;
    -			ret = ASIGetControlValue(cg->cameraNumber, ASI_TEMPERATURE, &temp, &bAuto);
    -			if (ret != ASI_SUCCESS)
    -			{
    -				Log(1, "  > %s: WARNING: ASIGetControlValue(ASI_TEMPERATURE) failed: %s\n", cg->ME, getRetCode(ret));
    -			}
    -			cg->lastSensorTemp = (long) ((double)temp / cg->divideTemperatureBy);
    -			if (cg->isColorCamera)
    -			{
    -				ret = ASIGetControlValue(cg->cameraNumber, ASI_WB_R, &l, &bAuto);
    -				if (ret != ASI_SUCCESS)
    -				{
    -					Log(1, "  > %s: WARNING: ASIGetControlValue(ASI_WB_R) failed: %s\n", cg->ME, getRetCode(ret));
    -				}
    -				cg->lastWBR = (double) l;
    +	char tempBuf[500] = { 0 };
    +	char *tb = tempBuf;
     
    -				ret = ASIGetControlValue(cg->cameraNumber, ASI_WB_B, &l, &bAuto);
    -				if (ret != ASI_SUCCESS)
    -				{
    -					Log(1, "  > %s: WARNING: ASIGetControlValue(ASI_WB_B) failed: %s\n", cg->ME, getRetCode(ret));
    -				}
    -				cg->lastWBB = (double) l;
    -			}
    +	cg->lastMean = computeHistogram(imageBuffer, *cg, true);
    +	sprintf(tb, " @ mean %.3f, %sgain %ld",
    +		cg->lastMean, cg->currentAutoGain ? "(auto) " : "", (long) cg->lastGain);
    +	cg->lastExposure_us = cg->currentExposure_us;
     
    -			if (cg->asiAutoBandwidth)
    -			{
    -				ret = ASIGetControlValue(cg->cameraNumber, ASI_BANDWIDTHOVERLOAD, &cg->lastAsiBandwidth, &wasAutoExposure);
    -				if (ret != ASI_SUCCESS)
    -				{
    -					Log(1, "  > %s: WARNING: ASIGetControlValue(ASI_BANDWIDTHOVERLOAD) failed: %s\n", cg->ME, getRetCode(ret));
    -				}
    -			}
    +	// Per ZWO, when in manual-exposure mode, the returned exposure length
    +	// should always be equal to the requested length;
    +	// they said, "there's no need to call ASIGetControlValue()".
    +	// When in auto-exposure mode the returned exposure length is what the driver thinks the
    +	// next exposure should be, and will eventually converge on the correct exposure.
    +
    +	Log(2, "  > GOT IMAGE%s.", tb);
    +	ret = ASIGetControlValue(cg->cameraNumber, ASI_EXPOSURE, &suggestedNextExposure_us, &wasAutoExposure);
    +	if (ret != ASI_SUCCESS)
    +	{
    +		Log(1, "  > WARNING: ASIGetControlValue(ASI_EXPOSURE) failed: %s\n",
    +			cg->ME, getRetCode(ret));
    +	}
    +	else if (cg->ZWOexposureType != ZWOsnap)
    +	{
    +		Log(3, cg->HB.useHistogram ? " Ignoring suggested next exposure of %s." : "  Suggested next exposure: %s.",
    +			length_in_units(suggestedNextExposure_us, true));
    +	}
    +	Log(2, "\n");
    +
    +	long temp;
    +	ret = ASIGetControlValue(cg->cameraNumber, ASI_TEMPERATURE, &temp, &bAuto);
    +	if (ret != ASI_SUCCESS)
    +	{
    +		Log(1, "  > %s: WARNING: ASIGetControlValue(ASI_TEMPERATURE) failed: %s\n",
    +			cg->ME, getRetCode(ret));
    +	}
    +	else
    +	{
    +		cg->lastSensorTemp = (long) ((double)temp / cg->divideTemperatureBy);
    +	}
    +
    +	if (cg->isColorCamera)
    +	{
    +		ret = ASIGetControlValue(cg->cameraNumber, ASI_WB_R, &l, &bAuto);
    +		if (ret != ASI_SUCCESS)
    +		{
    +			Log(1, "  > %s: WARNING: ASIGetControlValue(ASI_WB_R) failed: %s\n",
    +				cg->ME, getRetCode(ret));
    +		}
    +		else
    +		{
    +			cg->lastWBR = (double) l;
    +		}
    +
    +		ret = ASIGetControlValue(cg->cameraNumber, ASI_WB_B, &l, &bAuto);
    +		if (ret != ASI_SUCCESS)
    +		{
    +			Log(1, "  > %s: WARNING: ASIGetControlValue(ASI_WB_B) failed: %s\n",
    +				cg->ME, getRetCode(ret));
    +		}
    +		else
    +		{
    +			cg->lastWBB = (double) l;
     		}
     	}
    -	else {
    -		Log(0, "  > %s: ERROR: Not fetching exposure data because status is %s\n", cg->ME, getRetCode(status));
    +
    +	if (cg->asiAutoBandwidth)
    +	{
    +		ret = ASIGetControlValue(cg->cameraNumber, ASI_BANDWIDTHOVERLOAD, &cg->lastAsiBandwidth, &wasAutoExposure);
    +		if (ret != ASI_SUCCESS)
    +		{
    +			Log(1, "  > %s: WARNING: ASIGetControlValue(ASI_BANDWIDTHOVERLOAD) failed: %s\n", cg->ME, getRetCode(ret));
    +		}
     	}
     
    -//x Log(4, "xxxxxx takeOneExposure() returning %d\n", status);
    -	return status;
    +	return ASI_SUCCESS;
     }
     
     bool adjustGain = false;	// Should we adjust the gain? Set by user on command line.
    @@ -593,10 +672,12 @@ bool resetGainTransitionVariables(config cg)
     	// Determine the amount to adjust gain per image.
     	// Do this once per day/night or night/day transition (i.e., numGainChanges == 0).
     	// First determine how long an exposure and delay is, in seconds.
    -	// The user specifies the transition period in seconds,
    -	// but day exposure is in microseconds, night max is in milliseconds,
    -	// and delays are in milliseconds, so convert to seconds.
    +	// The transition period is in seconds, the max exposures and delays are in milliseconds,
    +	// so convert to seconds to compare to transition period.
     	float totalTimeInSec;
    +	totalTimeInSec = ((float) cg.currentMaxAutoExposure_us / US_IN_SEC) +
    +		((float) cg.currentDelay_ms / MS_IN_SEC);
    +/* xxxx remove after we know new gain algorithm works
     	if (dayOrNight == "DAY")
     	{
     		totalTimeInSec = (cg.dayExposure_us / US_IN_SEC) + (cg.dayDelay_ms / MS_IN_SEC);
    @@ -607,27 +688,32 @@ bool resetGainTransitionVariables(config cg)
     		// so use it instead of the exposure time.
     		totalTimeInSec = (cg.nightMaxAutoExposure_us / US_IN_SEC) + (cg.nightDelay_ms / MS_IN_SEC);
     	}
    +*/
     
     	gainTransitionImages = ceil(cg.gainTransitionTime / totalTimeInSec);
    +	Log(4, " gainTransitionImages=%d, gainTransitionTime=%d, totalTimeInSec=%f\n",
    +		gainTransitionImages, cg.gainTransitionTime, totalTimeInSec);
     	if (gainTransitionImages == 0)
     	{
    -		Log(-1, "*** INFORMATION: Not adjusting gain - your 'gaintransitiontime' (%d seconds) is less than the time to take one image plus its delay (%.1f seconds).\n", cg.gainTransitionTime, totalTimeInSec);
    +		Log(-1, "*** INFORMATION: Not adjusting gain - your 'Gain Transition Time' (%d seconds) is less than the time to take one image plus its delay (%.1f seconds).\n", cg.gainTransitionTime, totalTimeInSec);
     		return(false);
     	}
     
     	totalAdjustGain = cg.nightGain - cg.dayGain;
    -	perImageAdjustGain = ceil(totalAdjustGain / gainTransitionImages);	// spread evenly
    +	perImageAdjustGain = ceil((float) totalAdjustGain / gainTransitionImages);	// spread evenly
     	if (perImageAdjustGain == 0)
     		perImageAdjustGain = totalAdjustGain;
     	else
     	{
    -		// Since we can't adust gain by fractions, see if there's any "left over" after gainTransitionImages.
    +		// Since we can't adust gain by fractions,
    +		// see if there's any "left over" after gainTransitionImages.
     		// For example, if totalAdjustGain is 7 and we're adjusting by 3 each of 2 times,
     		// we need an extra transition to get the remaining 1 ((7 - (3 * 2)) == 1).
     		if (gainTransitionImages * perImageAdjustGain < totalAdjustGain)
     			gainTransitionImages++;		// this one will get the remaining amount
     	}
    -	Log(4, " totalAdjustGain=%d, gainTransitionImages=%d\n", totalAdjustGain, gainTransitionImages);
    +	Log(4, " totalAdjustGain=%d, gainTransitionImages=%d, perImageAdjustGain=%d\n",
    +		totalAdjustGain, gainTransitionImages, perImageAdjustGain);
     
     	return(true);
     }
    @@ -653,11 +739,16 @@ int determineGainChange(config cg)
     	int amt;	// amount to adjust gain on next picture
     	if (dayOrNight == "DAY")
     	{
    -		// During DAY, want to start out adding the full gain adjustment minus the increment on the first image,
    -		// then DECREASE by totalAdjustGain each exposure.
    +		// When DAY begins the last image was at nightGain but the first day
    +		// image (ignoring transition) is dayGain so we want to go down
    +		// from nightGain to dayGain.
    +		// Increase the first image's gain by perImageAdjustGain - totalAdjustGain (which
    +		// will be a big positive number), then increase the next image less,
    +		// and so on until we get to dayGain.
    +
     		// This assumes night gain is > day gain.
    +//x Log(4, ">> DAY: amt=%d, perImageAdjustGain=%d, numGainChanges=%d\n", amt, perImageAdjustGain, numGainChanges);
     		amt = totalAdjustGain - (perImageAdjustGain * numGainChanges);
    -Log(4, ">> DAY: amt=%d, totalAdjustGain=%d, perImageAdjustGain=%d, numGainChanges=%d\n", amt, totalAdjustGain, perImageAdjustGain, numGainChanges);
     		if (amt < 0)
     		{
     			amt = 0;
    @@ -666,9 +757,12 @@ Log(4, ">> DAY: amt=%d, totalAdjustGain=%d, perImageAdjustGain=%d, numGainChange
     	}
     	else	// NIGHT
     	{
    -		// During NIGHT, want to start out (nightGain-perImageAdjustGain),
    -		// then DECREASE by perImageAdjustGain each time, until we get to "nightGain".
    -		// This last image was at dayGain and we wen't to increase each image.
    +		// When NIGHT begins the last image was at dayGain but the first night
    +		// image (ignoring transition) is nightGain so we want to go up
    +		// from dayGain to nightGain.
    +		// Decrease the first image's gain by perImageAdjustGain - totalAdjustGain (which
    +		// will be a big negative number), then decrease the next image less,
    +		// and so on until we get to nightGain.
     		amt = (perImageAdjustGain * numGainChanges) - totalAdjustGain;
     		if (amt > 0)
     		{
    @@ -677,8 +771,10 @@ Log(4, ">> DAY: amt=%d, totalAdjustGain=%d, perImageAdjustGain=%d, numGainChange
     		}
     	}
     
    -	Log(4, "Adjusting %s gain by %d on next picture to %d (currentGain=%2f); will be gain change # %d of %d.\n",
    -		dayOrNight.c_str(), amt, amt+(int)cg.currentGain, cg.currentGain, numGainChanges, gainTransitionImages);
    +	Log(4, "Adjusting %s gain on next image by %d to %d (currentGain=%d); will be gain change # %d of %d.\n",
    +		dayOrNight.c_str(), amt, amt+(int)cg.currentGain,
    +		(int) cg.currentGain, numGainChanges, gainTransitionImages);
    +
     	return(amt);
     }
     
    @@ -687,12 +783,15 @@ bool checkMaxErrors(int *e, int maxErrors)
     {
     	// Once takeOneExposure() fails with a timeout, it seems to always fail,
     	// even with extremely large timeout values, so apparently ASI_ERROR_TIMEOUT doesn't
    -	// necessarily mean it's timing out. Exit which will cause us to be restarted.
    -	numErrors++; sleep(2);
    -	if (numErrors >= maxErrors)
    +	// necessarily mean it's timing out. Exit forcing us to be restarted.
    +	numTotalErrors++;
    +	numConsecutiveErrors++; sleep(2);
    +	if (numConsecutiveErrors >= maxErrors)
     	{
     		*e = EXIT_RESET_USB;		// exit code. Need to reset USB bus
    -		Log(0, "*** %s: ERROR: Maximum number of consecutive errors of %d reached; capture program exited.\n", CG.ME, maxErrors);
    +		Log(0, "*** %s: ERROR: Maximum number of consecutive errors of %d reached; capture program exited.\n",
    +			CG.ME, maxErrors);
    +		Log(1, "  > Total errors=%'d\n", numTotalErrors+1);
     		return(false);	// gets us out of inner and outer loop
     	}
     	return(true);
    @@ -703,17 +802,19 @@ bool checkMaxErrors(int *e, int maxErrors)
     
     int main(int argc, char *argv[])
     {
    -	CG.ME = argv[0];
    +	CG.ME = basename(argv[0]);
     	
    -	static char *a = getenv("ALLSKY_HOME");		// This must come before anything else
    -	if (a == NULL)
    +	CG.allskyHome = getenv("ALLSKY_HOME");
    +	if (CG.allskyHome == NULL)
     	{
     		Log(0, "*** %s: ERROR: ALLSKY_HOME not set!\n", CG.ME);
     		exit(EXIT_ERROR_STOP);
     	}
    -	else
    +
    +	if (! getCommandLineArguments(&CG, argc, argv, false))
     	{
    -		CG.allskyHome = a;
    +		// getCommandLineArguments outputs an error message.
    +		exit(EXIT_ERROR_STOP);
     	}
     
     	pthread_mutex_init(&mtxSaveImg, 0);
    @@ -776,16 +877,15 @@ int main(int argc, char *argv[])
     		Log(0, "*** %s: ERROR: ASIGetNumOfControls() returned: %s\n", CG.ME, getRetCode(asiRetCode));
     		exit(EXIT_ERROR_STOP);
     	}
    -	Log(4, "iNumOfCtrl=%d\n", iNumOfCtrl);
     	CG.ASIversion = ASIGetSDKVersion();
     
     	// Set defaults that depend on the camera type.
     	if (! setDefaults(&CG, ASICameraInfo))
     		closeUp(EXIT_ERROR_STOP);
     
    -	if (! getCommandLineArguments(&CG, argc, argv))
    +	if (CG.configFile[0] != '\0' && ! getConfigFileArguments(&CG))
     	{
    -		// getCommandLineArguents outputs an error message.
    +		// getConfigFileArguments() outputs error messages
     		exit(EXIT_ERROR_STOP);
     	}
     
    @@ -973,8 +1073,6 @@ int main(int argc, char *argv[])
     		setControl(CG.cameraNumber, ASI_BANDWIDTHOVERLOAD, CG.asiBandwidth, CG.asiAutoBandwidth ? ASI_TRUE : ASI_FALSE);
     	if (CG.gamma != NOT_CHANGED)
     		setControl(CG.cameraNumber, ASI_GAMMA, CG.gamma, ASI_FALSE);
    -	if (CG.offset != NOT_CHANGED)
    -		setControl(CG.cameraNumber, ASI_OFFSET, CG.offset, ASI_FALSE);
     	if (CG.flip != NOT_CHANGED)
     		setControl(CG.cameraNumber, ASI_FLIP, CG.flip, ASI_FALSE);
     
    @@ -988,9 +1086,10 @@ int main(int argc, char *argv[])
     	int originalITextY		= CG.overlay.iTextY;
     	int originalFontsize	= CG.overlay.fontsize;
     	int originalLinewidth	= CG.overlay.linewidth;
    -	// Have we displayed "not taking picture during day" message, if applicable?
    -	bool displayedNoDaytimeMsg	= false;
    -	int gainChange				= 0;		// how much to change gain up or down
    +	// Have we displayed "not taking picture during day/night" message, if applicable?
    +	bool displayedNoDaytimeMsg		= false;
    +	bool displayedNoNighttimeMsg	= false;
    +	int gainChange					= 0;		// how much to change gain up or down
     
     	// Display one-time messages.
     
    @@ -1013,16 +1112,16 @@ int main(int argc, char *argv[])
     		Log(4, "Extra Text File Age Disabled So Displaying Anyway\n");
     	}
     
    -	// Start taking pictures
    -
    -	if (! CG.videoOffBetweenImages)
    +	if (CG.ZWOexposureType == ZWOvideo && ! capturingVideo)
     	{
    +		// Start video capture; we'll stop when exiting.
     		asiRetCode = ASIStartVideoCapture(CG.cameraNumber);
     		if (asiRetCode != ASI_SUCCESS)
     		{
     			Log(0, "*** %s: ERROR: Unable to start video capture: %s\n", CG.ME, getRetCode(asiRetCode));
     			closeUp(EXIT_ERROR_STOP);
     		}
    +		capturingVideo = true;
     	}
     
     	while (bMain)
    @@ -1031,13 +1130,10 @@ int main(int argc, char *argv[])
     		dayOrNight = calculateDayOrNight(CG.latitude, CG.longitude, CG.angle);
     		std::string lastDayOrNight = dayOrNight;
     
    -		if (! CG.takeDarkFrames)
    -			currentAdjustGain = resetGainTransitionVariables(CG);
    -
     		if (CG.takeDarkFrames)
     		{
     			// We're doing dark frames so turn off autoexposure and autogain, and use
    -			// nightime gain, delay, max exposure, bin, and brightness to mimic a nightime shot.
    +			// nightime gain, delay, max exposure, and bin to mimic a nightime shot.
     			CG.currentSkipFrames = 0;
     			CG.currentAutoExposure = false;
     			CG.nightAutoExposure = false;
    @@ -1051,7 +1147,6 @@ int main(int argc, char *argv[])
     			CG.currentDelay_ms = CG.nightDelay_ms;
     			CG.currentMaxAutoExposure_us = CG.currentExposure_us = CG.nightMaxAutoExposure_us;
     			CG.currentBin = CG.nightBin;
    -			CG.currentBrightness = CG.nightBrightness;
     			if (CG.isColorCamera)
     			{
     				CG.currentAutoAWB = false;
    @@ -1089,7 +1184,8 @@ int main(int argc, char *argv[])
     
     			if (! CG.daytimeCapture)
     			{
    -				displayedNoDaytimeMsg = daytimeSleep(displayedNoDaytimeMsg, CG);
    +				// true == for daytime
    +				displayedNoDaytimeMsg = day_night_timeSleep(displayedNoDaytimeMsg, CG, true);
     
     				// No need to do any of the code below so go back to the main loop.
     				continue;
    @@ -1136,17 +1232,12 @@ int main(int argc, char *argv[])
     					length_in_units(CG.currentMaxAutoExposure_us, true));
     				CG.currentExposure_us = CG.currentMaxAutoExposure_us;
     			}
    +
     			// Don't use camera auto-exposure since we mimic it ourselves.
     			CG.HB.useHistogram = CG.dayAutoExposure;
    -			if (CG.HB.useHistogram)
    -			{
    -				// Only need to display this once, not every night-to-day transition...
    -				Log(4, "Turning off daytime ZWO auto-exposure to use Allsky auto-exposure.\n");
    -			}
     			// With the histogram method we NEVER use ZWO auto exposure - either the user said
     			// not to, or we turn it off ourselves.
     			CG.currentAutoExposure = false;
    -			CG.currentBrightness = CG.dayBrightness;
     			if (CG.isColorCamera)
     			{
     				CG.currentAutoAWB = CG.dayAutoAWB;
    @@ -1157,6 +1248,8 @@ int main(int argc, char *argv[])
     			CG.currentBin = CG.dayBin;
     			CG.currentGain = CG.dayGain;	// must come before determineGainChange() below
     			CG.currentMaxAutoGain = CG.dayMaxAutoGain;
    +			CG.currentAutoGain = CG.dayAutoGain;
    +			currentAdjustGain = resetGainTransitionVariables(CG);
     			if (currentAdjustGain)
     			{
     				// we did some nightime images so adjust gain
    @@ -1167,7 +1260,6 @@ int main(int argc, char *argv[])
     			{
     				gainChange = 0;
     			}
    -			CG.currentAutoGain = CG.dayAutoGain;
     			CG.myModeMeanSetting.currentMean = CG.myModeMeanSetting.dayMean;
     			CG.myModeMeanSetting.currentMean_threshold = CG.myModeMeanSetting.dayMean_threshold;
     			if (CG.isCooledCamera)
    @@ -1188,6 +1280,15 @@ int main(int argc, char *argv[])
     				justTransitioned = false;
     			}
     
    +			if (! CG.nighttimeCapture)
    +			{
    +				// false == for nighttime
    +				displayedNoNighttimeMsg = day_night_timeSleep(displayedNoNighttimeMsg, CG, false);
    +
    +				// No need to do any of the code below so go back to the main loop.
    +				continue;
    +			}
    +
     			Log(1, "==========\n=== Starting nighttime capture ===\n==========\n");
     
     			// We only skip initial frames if we are starting in nighttime and using auto-exposure.
    @@ -1200,19 +1301,9 @@ int main(int argc, char *argv[])
     				CG.currentExposure_us = CG.nightExposure_us;
     			}
     
    -if (CG.HB.useExperimentalExposure) {
     			// Don't use camera auto-exposure since we mimic it ourselves.
     			CG.HB.useHistogram = CG.nightAutoExposure;
    -			if (CG.HB.useHistogram)
    -			{
    -				Log(4, "Turning off nighttime ZWO auto-exposure to use Allsky auto-exposure.\n");
    -			}
     			CG.currentAutoExposure = false;
    -} else {
    -			CG.currentAutoExposure = CG.nightAutoExposure;
    -			CG.HB.useHistogram = false;		// only used during day
    -}
    -			CG.currentBrightness = CG.nightBrightness;
     			if (CG.isColorCamera)
     			{
     				CG.currentAutoAWB = CG.nightAutoAWB;
    @@ -1224,6 +1315,8 @@ if (CG.HB.useExperimentalExposure) {
     			CG.currentMaxAutoExposure_us = CG.nightMaxAutoExposure_us;
     			CG.currentGain = CG.nightGain;	// must come before determineGainChange() below
     			CG.currentMaxAutoGain = CG.nightMaxAutoGain;
    +			CG.currentAutoGain = CG.nightAutoGain;
    +			currentAdjustGain = resetGainTransitionVariables(CG);
     			if (currentAdjustGain)
     			{
     				// we did some daytime images so adjust gain
    @@ -1234,7 +1327,6 @@ if (CG.HB.useExperimentalExposure) {
     			{
     				gainChange = 0;
     			}
    -			CG.currentAutoGain = CG.nightAutoGain;
     			CG.myModeMeanSetting.currentMean = CG.myModeMeanSetting.nightMean;
     			CG.myModeMeanSetting.currentMean_threshold = CG.myModeMeanSetting.nightMean_threshold;
     			if (CG.isCooledCamera)
    @@ -1248,9 +1340,6 @@ if (CG.HB.useExperimentalExposure) {
     		CG.myModeMeanSetting.minMean = CG.myModeMeanSetting.currentMean - CG.myModeMeanSetting.currentMean_threshold;
     		CG.myModeMeanSetting.maxMean = CG.myModeMeanSetting.currentMean + CG.myModeMeanSetting.currentMean_threshold;
     
    -		CG.myModeMeanSetting.minMean *= 255;	// our algorithm compares to 0 - 255
    -		CG.myModeMeanSetting.maxMean *= 255;
    -
     		Log(3, "minMean=%.3f, maxMean=%.3f\n", CG.myModeMeanSetting.minMean, CG.myModeMeanSetting.maxMean);
     
     
    @@ -1273,13 +1362,21 @@ if (CG.HB.useExperimentalExposure) {
     		{
     			setControl(CG.cameraNumber, ASI_WB_R, CG.currentWBR, CG.currentAutoAWB ? ASI_TRUE : ASI_FALSE);
     			setControl(CG.cameraNumber, ASI_WB_B, CG.currentWBB, CG.currentAutoAWB ? ASI_TRUE : ASI_FALSE);
    +
    +			if (! CG.currentAutoAWB && ! CG.takeDarkFrames)
    +			{
    +				// We only read the actual values if in auto white balance; since we're not,
    +				// set the "last" values to the user-specified numbers.
    +				CG.lastWBR = CG.currentWBR;
    +				CG.lastWBB = CG.currentWBB;
    +			}
    +			else
    +			{
    +				CG.lastWBR = NOT_SET;
    +				CG.lastWBB = NOT_SET;
    +			}
     		}
    -		else if (! CG.currentAutoAWB && ! CG.takeDarkFrames)
    -		{
    -			// We only read the actual values if in auto white balance; since we're not, get them now.
    -			CG.lastWBR = CG.currentWBR;
    -			CG.lastWBB = CG.currentWBB;
    -		}
    +
     		if (CG.isCooledCamera)
     		{
     			setControl(CG.cameraNumber, ASI_COOLER_ON, CG.currentEnableCooler ? ASI_TRUE : ASI_FALSE, ASI_FALSE);
    @@ -1293,7 +1390,6 @@ if (CG.HB.useExperimentalExposure) {
     		if (CG.currentAutoExposure)
     		{
     			setControl(CG.cameraNumber, ASI_AUTO_MAX_EXP, CG.currentMaxAutoExposure_us / US_IN_MS, ASI_FALSE);
    -			setControl(CG.cameraNumber, ASI_AUTO_TARGET_BRIGHTNESS, CG.currentBrightness, ASI_FALSE);
     		}
     
     		if (numExposures == 0 || CG.dayBin != CG.nightBin)
    @@ -1311,7 +1407,12 @@ if (CG.HB.useExperimentalExposure) {
     
     			bufferSize = (long) (CG.width * CG.height * currentBpp);
     
    -// TODO: if not the first time, should we free the old pRgb?
    +			if (numExposures > 0)
    +			{
    +				// If not the first time, free the prior pRgb.
    +				pRgb.release();
    +			}
    +
     			if (CG.imageType == IMG_RAW16)
     			{
     				pRgb.create(cv::Size(CG.width, CG.height), CV_16UC1);
    @@ -1354,7 +1455,6 @@ if (CG.HB.useExperimentalExposure) {
     		// Wait for switch day time -> night time or night time -> day time
     		while (bMain && lastDayOrNight == dayOrNight)
     		{
    -//x Log(4, "xxx just entered outside 'while' loop\n");
     			// date/time is added to many log entries to make it easier to associate them
     			// with an image (which has the date/time in the filename).
     			exposureStartDateTime = getTimeval();
    @@ -1363,7 +1463,8 @@ if (CG.HB.useExperimentalExposure) {
     			// Unfortunately our histogram method only does exposure, not gain, so we
     			// can't say what gain we are going to use.
     			Log(2, "-----\n");
    -			Log(1, "STARTING EXPOSURE at: %s   @ %s\n", exposureStart, length_in_units(CG.currentExposure_us, true));
    +			Log(1, "STARTING EXPOSURE at: %s   @ %s\n",
    +				exposureStart, length_in_units(CG.currentExposure_us, true));
     
     			// Get start time for overlay. Make sure it has the same time as exposureStart.
     			if (CG.overlay.showTime)
    @@ -1371,16 +1472,14 @@ if (CG.HB.useExperimentalExposure) {
     				sprintf(bufTime, "%s", formatTime(exposureStartDateTime, CG.timeFormat));
     			}
     
    -//x Log(4, "xxx calling takeOneExposure() from outside 'while' loop\n");
     			asiRetCode = takeOneExposure(&CG, pRgb.data);
    -//x Log(4, "xxx >> takeOneExposure() returned %s\n", getRetCode(asiRetCode));
     			if (asiRetCode == ASI_SUCCESS)
     			{
    -				numErrors = 0;
    +				numConsecutiveErrors = 0;
     				numExposures++;
     				bool hitMinOrMax = false;
     
    -				CG.lastFocusMetric = CG.overlay.showFocus ? (int)round(get_focus_metric(pRgb)) : -1;
    +				CG.lastFocusMetric = CG.determineFocus ? (int)round(get_focus_metric(pRgb)) : -1;
     
     				if (numExposures == 0 && CG.preview)
     				{
    @@ -1395,60 +1494,20 @@ if (CG.HB.useExperimentalExposure) {
     
     					attempts = 0;
     
    -					int minAcceptableMean = CG.myModeMeanSetting.minMean;
    -					int maxAcceptableMean = CG.myModeMeanSetting.maxMean;
    +					double minAcceptableMean = CG.myModeMeanSetting.minMean;
    +					double maxAcceptableMean = CG.myModeMeanSetting.maxMean;
     					long tempMinExposure_us = CG.cameraMinExposure_us;
     					long tempMaxExposure_us = CG.cameraMaxExposure_us;
     					long newExposure_us = 0;
     
    -// TODO: dump Brightness - user can adjust Target Mean or Manual Exposure.
    -					if (CG.currentBrightness != CG.defaultBrightness)
    -					{
    -						// Adjust brightness based on Brightness.
    -						// The default value has no adjustment.
    -						// The only way we can do this easily is via adjusting the exposure.
    -						// We could apply a stretch to the image, but that's more difficult.
    -						// Sure would be nice to see how ZWO handles this variable.
    -						// We asked but got a useless reply.
    -						// Values below the default make the image darker; above make it brighter.
    -						float exposureAdjustment = 1.0;
    -
    -						// Adjustments of DEFAULT_BRIGHTNESS up or down make the image this much darker/lighter.
    -						// Don't want the max brightness to give pure white.
    -						//xxx May have to play with this number, but it seems to work ok.
    -						// 100 * this number is the percent to change.
    -						const float adjustmentAmountPerMultiple = 0.12;
    -
    -						// The amount doesn't change after being set, so only display once.
    -						static bool showedMessage = false;
    -						if (! showedMessage)
    -						{
    -							float numMultiples;
    -
    -							// Determine the adjustment amount - only done once.
    -							// See how many multiples we're different.
    -							// If currentBrightness < default then numMultiples will be negative,
    -							// which is ok - it just means the multiplier will be less than 1.
    -
    -							numMultiples = (float)(CG.currentBrightness - CG.defaultBrightness) / CG.defaultBrightness;
    -							exposureAdjustment = 1 + (numMultiples * adjustmentAmountPerMultiple);
    -							Log(4, "  > >>> Adjusting exposure x %.2f (%.1f%%) for brightness\n", exposureAdjustment, (exposureAdjustment - 1) * 100);
    -							showedMessage = true;
    -						}
    -
    -						// Now adjust the variables
    -						minAcceptableMean *= exposureAdjustment;
    -						maxAcceptableMean *= exposureAdjustment;
    -					}
    -
     					// Keep track of whether or not we're bouncing around, for example,
     					// one exposure is less than the min and the second is greater than the max.
     					// When that happens we don't want to set the min to the second exposure
     					// or else we'll never get low enough.
     					// Negative is below lower limit, positive is above upper limit.
    -					int priorMean = NOT_SET;		// The mean for the image before the last one.
    -					int priorMeanDiff = 0;
    -					int lastMeanDiff = 0;	// like priorMeanDiff but for next exposure
    +					double priorMean = NOT_SET;		// The mean for the image before the last one.
    +					double priorMeanDiff = 0.0;
    +					double lastMeanDiff = 0.0;	// like priorMeanDiff but for next exposure
     					int numPingPongs = 0;
     
     					if (CG.lastMean < minAcceptableMean)
    @@ -1464,38 +1523,46 @@ if (CG.HB.useExperimentalExposure) {
     					while ((CG.lastMean < minAcceptableMean || CG.lastMean > maxAcceptableMean) &&
     						    ++attempts <= maxHistogramAttempts)
     					{
    -						int acceptableMean;
    -						float multiplier = 1.10;
    +						double acceptableMean;
    +						double multiplier = 1.10;
     						char const *acceptableType;
     						if (CG.lastMean < minAcceptableMean) {
     							acceptableMean = minAcceptableMean;
    +// acceptableMean = 0.270
     							acceptableType = "min";
     						} else {
     							acceptableMean = maxAcceptableMean;
     							acceptableType = "max";
    -							multiplier = 1 / multiplier;
    +							multiplier = 1.0 / multiplier;
     						}
     
     						// If lastMean/acceptableMean is 9/90, it's 1/10th of the way there,
     						// so multiple exposure by 90/9 (10).
     						// ZWO cameras don't appear to be linear so increase the multiplier amount some.
    -						float multiply;
    -						if (CG.lastMean == 0) {
    -							// TODO: is this correct?
    -							multiply = ((double)acceptableMean) * multiplier;
    +						double multiply;
    +						if (CG.lastMean == 0.0) {
    +							// means are less than 1 so force "multiply" to be > 1.
    +							multiply = (1 + acceptableMean) * multiplier;
    +// multiple = 0.270 * 1.1    0.297
     						} else {
    -							multiply = ((double)acceptableMean / CG.lastMean) * multiplier;
    +							multiply = (acceptableMean / CG.lastMean) * multiplier;
    +/// multiply = (0.478 / 0.473) * 1.1     (1.0105708) * 1.1          1.112
     						}
    +// TODO FIX: xxxxxxxxxxxxxxxxxx
     						long exposureDiff_us = (CG.lastExposure_us * multiply) - CG.lastExposure_us;
    +// exposureDiff_us = (100,000 * 0.297) - 100,000      (29,700) - 100,000      -70,300
     						long exposureDiffBeforeAgression_us = exposureDiff_us;
    +// exposureDiffBeforeAgression_us = -70,300
     
     						// Adjust by aggression setting.
    -						if (CG.aggression != 100 && CG.currentSkipFrames <= 0 && exposureDiff_us != 0)
    +						if (CG.aggression != 100 && exposureDiff_us != 0)
     						{
     							exposureDiff_us *= (float)CG.aggression / 100;
    +// exposureDiff_us = -70,300 * (85/100)     -70,300 * .85     -59,755
     						}
     
     						newExposure_us = CG.lastExposure_us + exposureDiff_us;
    +// newExposure_us = 100,000 + -59,755       40,245
     						// Assume max auto exposure is <= max camera exposure.
     						if (newExposure_us > CG.currentMaxAutoExposure_us) {
     							hitMinOrMax = true;
    @@ -1503,27 +1570,37 @@ if (CG.HB.useExperimentalExposure) {
     								newExposure_us, CG.currentMaxAutoExposure_us);
     							newExposure_us = CG.currentMaxAutoExposure_us;
     						} else {
    -							Log(3, "    > Next exposure change: %'ld us (%'ld pre agression) to %'ld (* %.3f) [CG.lastExposure_us=%'ld, %sAcceptableMean=%d, CG.lastMean=%d]\n",
    +							Log(3, "    > Next exposure change: %'ld us (%'ld pre agression) to %'ld (* %.3f) [CG.lastExposure_us=%'ld, %sAcceptableMean=%.3f, CG.lastMean=%.3f]\n",
     								exposureDiff_us, exposureDiffBeforeAgression_us,
     								newExposure_us, multiply, CG.lastExposure_us,
    -								acceptableType, acceptableMean, (int)CG.lastMean);
    +								acceptableType, acceptableMean, CG.lastMean);
    +// -59.755, -70,300
    +// 40,245, 0.297, 100,000,
    +// min, 0.270, 0
    +// Next exposure change: -59,755 us (-70,300 pre agression) to 40,245 (* 0.297) [CG.lastExposure_us=100,000, minAcceptableMean=0.270, CG.lastMean=0.000]
    +
    +/// > GOT IMAGE @ mean 0.473, gain 0.
    +/// > Next exposure change: 207 us (244 pre agression) to 2,379 (* 1.112) [CG.lastExposure_us=2,172, minAcceptableMean=0.478, CG.lastMean=0.473]
    +/// >> Retry 1 @ 2,379 us, min=2,172 us, max=2,000,000,000 us
    +/// > GOT IMAGE @ mean 0.516, gain 0.
    +/// > Good image: mean 0.516 within range of 0.478 to 0.526 +++++++++
     						}
     
    -						if (priorMeanDiff > 0 && lastMeanDiff < 0)
    +						if (priorMeanDiff > 0.0 && lastMeanDiff < 0.0)
     						{ 
     							++numPingPongs;
    -							Log(2, "    > xxx lastMean was %d and went from %d above max of %d to %d below min of %d, is now at %d;\n",
    +							Log(2, "    > xxx lastMean was %.3f and went from %.3f above max of %.3f to %.3f below min of %.3f, is now at %.3f;\n",
     								priorMean, priorMeanDiff, maxAcceptableMean, -lastMeanDiff,
    -									minAcceptableMean, (int)CG.lastMean);
    +									minAcceptableMean, CG.lastMean);
     						} 
     						else
     						{
    -							if (priorMeanDiff < 0 && lastMeanDiff > 0)
    +							if (priorMeanDiff < 0.0 && lastMeanDiff > 0.0)
     							{
     								++numPingPongs;
    -								Log(2, "    > xxx lastMean was %d and went from %d below min of %d to %d above max of %d, is now at %d;\n",
    +								Log(2, "    > xxx lastMean was %.3f and went from %.3f below min of %.3f to %.3f above max of %.3f, is now at %.3f;\n",
     									priorMean, -priorMeanDiff, minAcceptableMean, lastMeanDiff,
    -									maxAcceptableMean, (int)CG.lastMean);
    +									maxAcceptableMean, CG.lastMean);
     							}
     							else
     							{
    @@ -1533,6 +1610,7 @@ if (CG.HB.useExperimentalExposure) {
     							if (CG.lastMean < minAcceptableMean)
     							{
     								tempMinExposure_us = CG.currentExposure_us;
    +// TODO: ???? set tempMaxExposure_us to ???
     							} 
     							else if (CG.lastMean > maxAcceptableMean)
     							{
    @@ -1542,18 +1620,19 @@ if (CG.HB.useExperimentalExposure) {
     
     						if (numPingPongs >= 3)
     						{
    -printf(" > xxx newExposure_us=%s, CG.lastExposure_us=%s, CG.currentExposure_us=%s,",
    -	length_in_units(newExposure_us, true), length_in_units(CG.lastExposure_us, true),
    +Log(3, "     > xxx newExposure_us=%s, CG.lastExposure_us=%s, CG.currentExposure_us=%s,",
    +	length_in_units(newExposure_us, true),
    +	length_in_units(CG.lastExposure_us, true),
     	length_in_units(CG.currentExposure_us, true));
     							newExposure_us = (newExposure_us + CG.lastExposure_us) / 2;
    -printf(" new newExposure_us=%s\n", length_in_units(newExposure_us, true));
    +Log(3, " new newExposure_us=%s\n", length_in_units(newExposure_us, true));
     							Log(3, " > Ping-Ponged %d times, setting exposure to mid-point of %s\n", numPingPongs, length_in_units(newExposure_us, true));
     
     // XXXX testing
     							// To try and help, add (or subtract) the numPingPongs percent to the exposure.
     							// For example, if newExposure_us == 200 and numPingPongs == 4, add 4% (8 us = 4% * 200).
     							long us = (long) (newExposure_us * ((double)numPingPongs / 100.0));
    -Log(3, "================ Adding %'ld us\n", us);
    +							Log(3, "================ Adding %'ld us\n", us);
     							newExposure_us += us;
     							if (tempMaxExposure_us < newExposure_us)
     								tempMaxExposure_us = newExposure_us;
    @@ -1564,11 +1643,13 @@ Log(3, "================ Adding %'ld us\n", us);
     long saved_newExposure_us = newExposure_us;
     						newExposure_us = std::max(tempMinExposure_us, newExposure_us);
     						newExposure_us = std::min(tempMaxExposure_us, newExposure_us);
    -if (saved_newExposure_us != newExposure_us)
    -{
    -	Log(3, "> xxx newExposure_us changed from %s to %s due to tempMin/tempMax\n",
    -		length_in_units(saved_newExposure_us, true), length_in_units(newExposure_us, true));
    -}
    +						if (saved_newExposure_us != newExposure_us)
    +						{
    +							Log(3, "    > xxx newExposure_us changed from %s to %s due to tempMin/tempMax",
    +								length_in_units(saved_newExposure_us, true), length_in_units(newExposure_us, true));
    +							Log(3, " (%s/%s)\n",
    +								length_in_units(tempMinExposure_us, true), length_in_units(tempMaxExposure_us, true));
    +						}
     
     						if (newExposure_us == CG.currentExposure_us)
     						{
    @@ -1588,9 +1669,7 @@ if (saved_newExposure_us != newExposure_us)
     						priorMean = CG.lastMean;
     						priorMeanDiff = lastMeanDiff;
     
    -//x Log(4, "xxxxxx inside 'Retry' loop, calling takeOneExposure()\n");
     						asiRetCode = takeOneExposure(&CG, pRgb.data);
    -//x Log(4, "xxxxxx >> takeOneExposure() returned %s\n", getRetCode(asiRetCode));
     						if (asiRetCode == ASI_SUCCESS)
     						{
     							if (CG.lastMean < minAcceptableMean)
    @@ -1598,7 +1677,7 @@ if (saved_newExposure_us != newExposure_us)
     							else if (CG.lastMean > maxAcceptableMean)
     								lastMeanDiff = CG.lastMean - maxAcceptableMean;
     							else
    -								lastMeanDiff = 0;
    +								lastMeanDiff = 0.0;
     
     							continue;
     						}
    @@ -1610,9 +1689,11 @@ if (saved_newExposure_us != newExposure_us)
     
     					if (asiRetCode != ASI_SUCCESS)
     					{
    -						Log(2,"  > Sleeping %s from failed exposure\n",
    -							length_in_units(CG.currentDelay_ms * US_IN_MS, false));
    -						usleep(CG.currentDelay_ms * US_IN_MS);
    +						// Sleep half the normal time.
    +						long s = (CG.currentDelay_ms / 2 * US_IN_MS) * 0.5;
    +						Log(2,"  > Sleeping %s us from failed exposure\n",
    +							length_in_units(s, false));
    +						usleep(s);
     						// Don't save the file or do anything below.
     						continue;
     					}
    @@ -1620,13 +1701,13 @@ if (saved_newExposure_us != newExposure_us)
     					if (CG.lastMean >= minAcceptableMean && CG.lastMean <= maxAcceptableMean)
     					{
     						// +++ at end makes it easier to see in log file
    -						Log(2, "  > Good image: mean %d within range of %d to %d ++++++++++\n",
    -							(int)CG.lastMean, minAcceptableMean, maxAcceptableMean);
    +						Log(2, "  > Good image: mean %.3f within range of %.3f to %.3f +++++++++\n",
    +							CG.lastMean, minAcceptableMean, maxAcceptableMean);
     					}
     					else if (attempts > maxHistogramAttempts)
     					{
    -						 Log(2, "  > max attempts reached - using exposure of %s with mean %d\n",
    -							length_in_units(CG.currentExposure_us, true), (int)CG.lastMean);
    +						 Log(2, "  > max attempts reached - using exposure of %s with mean %.3f\n",
    +							length_in_units(CG.currentExposure_us, true), CG.lastMean);
     					}
     					else if (attempts >= 1)
     					{
    @@ -1680,21 +1761,21 @@ if (saved_newExposure_us != newExposure_us)
     						}
     						else
     						{
    -							Log(2, "  > Stopped trying, using exposure of %s with mean %d, min=%d, max=%d\n",
    +							Log(2, "  > Stopped trying, using exposure of %s with mean %.3f, min=%.3f, max=%.3f\n",
     								length_in_units(CG.currentExposure_us, false),
    -								(int)CG.lastMean, minAcceptableMean, maxAcceptableMean);
    +								CG.lastMean, minAcceptableMean, maxAcceptableMean);
     						}
     						 
     					}
     					else if (CG.currentExposure_us == CG.cameraMinExposure_us)
     					{
    -						Log(3, "  > Did not make any additional attempts - at min exposure limit of %s, mean %d\n",
    -							length_in_units(CG.cameraMinExposure_us, false), (int)CG.lastMean);
    +						Log(3, "  > Did not make any additional attempts - at min exposure limit of %s, mean %.3f\n",
    +							length_in_units(CG.cameraMinExposure_us, false), CG.lastMean);
     					}
     					else if (CG.currentExposure_us == CG.cameraMaxExposure_us)
     					{
    -						Log(3, "  > Did not make any additional attempts - at max exposure limit of %s, mean %d\n",
    -							length_in_units(CG.cameraMaxExposure_us, false), (int)CG.lastMean);
    +						Log(3, "  > Did not make any additional attempts - at max exposure limit of %s, mean %.3f\n",
    +							length_in_units(CG.cameraMaxExposure_us, false), CG.lastMean);
     					}
     
     				} else {
    @@ -1831,3 +1912,4 @@ if (saved_newExposure_us != newExposure_us)
     
     	closeUp(EXIT_OK);
     }
    +
    diff --git a/src/include/ASICamera2.h b/src/include/ASICamera2.h
    index 96061ffef..44dbfb9fa 100644
    --- a/src/include/ASICamera2.h
    +++ b/src/include/ASICamera2.h
    @@ -177,6 +177,7 @@ typedef enum ASI_CONTROL_TYPE{ //Control type//
     	ASI_ANTI_DEW_HEATER,
     	ASI_FAN_ADJUST,
     	ASI_PWRLED_BRIGNT,
    +	ASI_USBHUB_RESET,
     	ASI_GPS_SUPPORT,
     	ASI_GPS_START_LINE,
     	ASI_GPS_END_LINE,
    @@ -1040,140 +1041,6 @@ ASI_ERROR_GPS_FPGA_ERR : failed to read or write data to FPGA
     ASI_ERROR_GPS_DATA_INVALID : GPS has not yet found the satellite or FPGA cannot read GPS data
     ***************************************************************************/
     ASICAMERA_API ASI_ERROR_CODE ASIGPSGetData(int iCameraID, ASI_GPS_DATA* startLineGPSData, ASI_GPS_DATA* endLineGPSData);
    -
    -
    -/***************************************************************************
    -Description:
    -Enable the debug log output
    -Paras:
    -int CameraID: this is get from the camera property use the API ASIGetCameraProperty.
    -ASI_BOOL bEnable: true to enable the log output and false to disable.
    -
    -return:
    -ASI_SUCCESS : Operation is successful
    -ASI_ERROR_CAMERA_CLOSED : camera didn't open
    -ASI_ERROR_INVALID_ID  :no camera of this ID is connected or ID value is out of boundary
    -***************************************************************************/
    -ASICAMERA_API ASI_ERROR_CODE ASIEnableDebugLog(int iCameraID, ASI_BOOL bEnable);
    -
    -/***************************************************************************
    -Description:
    -Get the status that if the debug log file output is enabled
    -Paras:
    -int CameraID: this is get from the camera property use the API ASIGetCameraProperty.
    -ASI_BOOL *bEnable: true if the log output is enabled and false if the log output is disabled.
    -
    -return:
    -ASI_SUCCESS : Operation is successful
    -ASI_ERROR_CAMERA_CLOSED : camera didn't open
    -ASI_ERROR_INVALID_ID  :no camera of this ID is connected or ID value is out of boundary
    -***************************************************************************/
    -ASICAMERA_API ASI_ERROR_CODE ASIGetDebugLogIsEnabled(int iCameraID, ASI_BOOL *bEnable);
    -
    -//#define ASIPRODUCE //API for Produce. It needs to be commented out when it is released to the public
    -#ifdef ASIPRODUCE
    -//#define SEESTAR_PRODUCE //Only for Seestar production. If you want to release USB cameras' production SDK, please comment this Macro
    -ASICAMERA_API ASI_ERROR_CODE  ASISaveHPCTable(int iCameraID, unsigned char *table, long len);
    -/***************************************************************************
    -Description:
    -Get the number of HPC
    -
    -piVal: the number of HPC.
    -***************************************************************************/
    -ASICAMERA_API ASI_ERROR_CODE  ASIHPCNumber(int iCameraID, int* piVal);
    -/***************************************************************************
    -Description:
    -Get the HPC table. When you malloc the table, make sure the length is max_width * max_height / 8
    -
    -table: the array which contains HPC table.
    -len: max_width * max_height / 8
    -Note: note that the HPC table you get is compressed.One byte contains 8 pixels.
    -***************************************************************************/
    -ASICAMERA_API ASI_ERROR_CODE  ASIGetHPCTable(int iCameraID, unsigned char* table, long len);
    -ASICAMERA_API ASI_ERROR_CODE  ASIEnableHPC(int iCameraID, ASI_BOOL bVal);
    -ASICAMERA_API int ASIGetIDByIndex(int iCameraIndex);
    -/***************************************************************************
    -Description:
    -The sensor is set to output continuous gradient image to facilitate the detection of snowflake screen
    -
    -bEnable: ASI_ TRUE means output continuous gradient image, ASI_ FALSE indicates the actual image
    -***************************************************************************/
    -ASICAMERA_API ASI_ERROR_CODE  ASIEnableSnowTest(int iCameraID, ASI_BOOL bEnable);
    -/***************************************************************************
    -Description:
    -Write sensor register directly. Used for ASIProduce only.
    -
    -iAddr: the address of the register
    -iValue: the value to write to the register
    -***************************************************************************/
    -ASICAMERA_API ASI_ERROR_CODE  ASIWriteSonyReg(int iCameraID, unsigned short iAddr, unsigned char iValue);
    -/***************************************************************************
    -Description:
    -Read FPGA register directly. Used for ASIProduce only.
    -
    -iAddr: the address of the register
    -iValue: the value read from the register
    -***************************************************************************/
    -ASICAMERA_API ASI_ERROR_CODE  ASIReadFPGAReg(int iCameraID, unsigned short iAddr, unsigned char* piValue);
    -
    -/***************************************************************************
    -Description:
    -Read special register. Used for ASIProduce(Seestar) only.
    -
    -iCmd: the cmd, for Seestar E-compass, it's 0xE8
    -iAddr: the address of the register, for Seestar E-compass, it's 0 
    -iValue: the value read from the register, for Seestar E-compass, it's 0x0E48
    -***************************************************************************/
    -ASICAMERA_API ASI_ERROR_CODE  ASIReadSpecialReg(int iCameraID, unsigned char iCmd, unsigned short iAddr, unsigned short* piValue);
    -
    -ASICAMERA_API int ASIGetPID(int iCameraID);
    -
    -/***************************************************************************
    -Description:
    -Switch 2 group filter. Used for ASIProduce only.
    -
    -group: the group of filter
    -val: left or right position
    -***************************************************************************/
    -ASICAMERA_API ASI_ERROR_CODE  ASIS50FilterSwitch(int iCameraID, int group, int val);
    -
    -/***************************************************************************
    -Description:
    -Enable and disable heater of S50. Used for ASIProduce only.
    -
    -val: enable or disable
    -***************************************************************************/
    -ASICAMERA_API ASI_ERROR_CODE  ASIS50Heater(int iCameraID, ASI_BOOL bEnable);
    -
    -/***************************************************************************
    -Description:
    -Get the status of RA and DEC of S50. Used for ASIProduce only.
    -
    -bRaEn: TRUE is block, FALSE is not block
    -bDecEn: TRUE is block, FALSE is not block
    -***************************************************************************/
    -ASICAMERA_API ASI_ERROR_CODE  ASIS50RADECSensor(int iCameraID, ASI_BOOL* bRaEn, ASI_BOOL* bDecEn);
    -
    -#endif // ASIPRODUCE
    -
    -//#define INNER_TEST //Inner test£¬do not release to public. It must be commented before release
    -//#define SMLTEST //Test by SML. It must be commented before release
    -
    -#ifdef INNER_TEST
    -/***************************************************************************
    -Description:
    -Set the Cooling Temperature parameters. Used for ASIProduce only.
    -
    -nTempSegment: The separator temperature. When the temperature is higher than the separation point,
    -adopt faster cooling speed to improve efficiency. When the temperature is lower than the separation point,
    -use a slower speed to cool the camera to avoid fogging or icing.
    -nHighTempDurationSec: The cooling time from current temperature to separator temperature.
    -nLowTempDurationSec: The cooling time form separator temperature to target temperature.
    -***************************************************************************/
    -ASICAMERA_API ASI_ERROR_CODE  ASISetTempControlValue(int iCameraID, int nTempSegment, int nHighTempDurationSec, int nLowTempDurationSec);
    -#endif // INNER_TEST
    -
    -
     #ifdef __cplusplus
     }
     #endif
    diff --git a/src/include/allsky_common.h b/src/include/allsky_common.h.repo
    similarity index 90%
    rename from src/include/allsky_common.h
    rename to src/include/allsky_common.h.repo
    index a86113feb..6ef605510 100644
    --- a/src/include/allsky_common.h
    +++ b/src/include/allsky_common.h.repo
    @@ -26,11 +26,13 @@
     
     // NOT_SET are items that aren't set yet and will be calculated at run time.
     // NOT_CHANGED items are command-line arguments where the default is camera-dependent,
    +// NO_DEFAULT items don't have a default value.
     // and we can't use NOT_SET because -1 may be a legal value.
     // IS_DEFAULT means the value is the same as the camera default, so don't pass to camera program
     // since it'll use the default anyway.
     #define NOT_SET						-1
     #define NOT_CHANGED					-999999
    +#define NO_DEFAULT					-999998
     #define	IS_DEFAULT					NOT_CHANGED
     
     // Defaults
    @@ -52,10 +54,10 @@
     #define DEFAULT_MAXMEAN_THRESHOLD_RPi	1.0
     
     // Got these by trial and error. 128 is more-or-less half the max of 255.
    -#define DEFAULT_DAYMEAN_ZWO				(128.0 / 255)	// matches old way
    -#define DEFAULT_DAYMEAN_THRESHOLD_ZWO	(6.0 / 255)		// matches old way
    -#define DEFAULT_NIGHTMEAN_ZWO			(75.0 / 255)	// TODO: pure guess as of May 22, 2023
    -#define DEFAULT_NIGHTMEAN_THRESHOLD_ZWO	(6.0 / 255)
    +#define DEFAULT_DAYMEAN_ZWO				(128.0 / 255.0)	// matches old way
    +#define DEFAULT_DAYMEAN_THRESHOLD_ZWO	(6.0 / 255.0)		// matches old way
    +#define DEFAULT_NIGHTMEAN_ZWO			(75.0 / 255.0)	// TODO: pure guess as of May 22, 2023
    +#define DEFAULT_NIGHTMEAN_THRESHOLD_ZWO	(6.0 / 255.0)
     #define DEFAULT_MEAN_P0_ZWO				5.0		// TODO: set after porting modemean to ZWO
     #define DEFAULT_MEAN_P1_ZWO				20.0	// TODO: set after porting modemean to ZWO
     #define DEFAULT_MEAN_P2_ZWO				45.0	// TODO: set after porting modemean to ZWO
    @@ -91,6 +93,13 @@ enum cameraType {
     	ctRPi
     };
     
    +enum ZWOexposure {
    +	ZWOsnap,		// snapshot mode
    +	ZWOvideoOff,	// video mode with video off between shots
    +	ZWOvideo,		// video mode with video on all the time (original method)
    +	ZWOend
    +};
    +
     // Use long instead of int so we can use validateLong() without creating validateInt().
     struct overlay {
     	char const *ImgText					= "";
    @@ -114,7 +123,6 @@ struct overlay {
     	bool showExposure					= false;
     	bool showTemp						= false;
     	bool showGain						= false;
    -	bool showBrightness					= false;
     	bool showMean						= false;
     	bool showFocus						= false;
     	bool showHistogramBox				= false;
    @@ -127,7 +135,6 @@ struct overlay {
     // Histogram Box, ZWO only
     struct HB {
     	bool useHistogram					= false;		// Should we use histogram auto-exposure?
    -	bool useExperimentalExposure		= false;		// Should histogram auto-exposure at night?
     	int histogramBoxSizeX				= 500;			// width of box in pixels
     	int currentHistogramBoxSizeX		= NOT_CHANGED;
     	int histogramBoxSizeY				= 500;			// height of box in pixels
    @@ -175,13 +182,17 @@ struct config {			// for configuration variables
     
     	// Camera number, type and model
     	int numCameras						= 0;			// Number of cameras physically connected
    -	int cameraNumber					= 0;			// 0 to number-of-cameras-attached minus 1
    -	cameraType ct						= ctRPi;
    -	char const *cm						= "";
    +	cameraType ct						= ctRPi;		// default camera type
    +	char const *cm						= "";			// camera model
    +	int cameraNumber					= 0;			// camera number index (starts at 0)
     
     	// Settings can be in the config file and/or command-line.
    -	char const *allskyHome				= "";			// full pathname to home of Allsky
    +	char const *allskyHome				= "XX_ALLSKY_HOME_XX";	// full pathname to home of Allsky
     	char const *configFile				= "";
    +	// File with list of connected cameras
    +	char const *connectedCamerasFile	= "XX_CONNECTED_CAMERAS_FILE_XX";
    +	// File with info on all supported RPI cameras.
    +	char const *RPI_cameraInfoFile		= "XX_RPI_CAMERA_INFO_FILE_XX";
     
     	bool isColorCamera					= false;		// Is the camera color or mono?
     	bool isCooledCamera					= false;		// Does the camera have a cooler?
    @@ -210,14 +221,17 @@ struct config {			// for configuration variables
     	bool saveCC							= false;		// Save camera controls file?
     	bool tty							= false;		// Running on a tty?
     	bool preview						= false;		// Display a preview windoe?
    -	bool daytimeCapture					= false;		// Capture images during daytime?
    -	bool daytimeSave					= false;		// Save images during daytime?
     	char const *timeFormat				= "%Y%m%d %H:%M:%S";
     	char const *extraArgs				= "";			// Optional extra arguments passed on
    +	bool determineFocus					= false;
     
     	// To make the code cleaner, comments are only given for daytime variables.
     
     	// Settings not camera-dependent.
    +	bool daytimeCapture					= true;			// Capture images during daytime?
    +	bool daytimeSave					= false;		// Save images during daytime?
    +	bool nighttimeCapture				= true;
    +	bool nighttimeSave					= true;
     	long dayDelay_ms					= 10 * MS_IN_SEC;	// Delay between capture end and start
     	long nightDelay_ms					= 10 * MS_IN_SEC;
     	long minDelay_ms					= NOT_SET;			// Minimum delay between images
    @@ -250,8 +264,6 @@ struct config {			// for configuration variables
     	long nightExposure_us				= 20 * US_IN_SEC;
     	double temp_nightExposure_ms		= nightExposure_us / US_IN_MS;
     
    -	long dayBrightness					= NOT_CHANGED;		// Brightness requested by user
    -	long nightBrightness				= NOT_CHANGED;
     	bool dayAutoGain					= true;				// Use auto-gain?
     	bool nightAutoGain					= true;
     	double dayMaxAutoGain				= NOT_CHANGED;		// Max gain in auto-gain mode
    @@ -274,7 +286,6 @@ struct config {			// for configuration variables
     	double contrast						= NOT_CHANGED;
     	double sharpness					= NOT_CHANGED;
     	long gamma							= NOT_CHANGED;
    -	long offset							= NOT_CHANGED;
     	bool asiAutoBandwidth				= true;
     	long asiBandwidth					= NOT_CHANGED;
     	char const *fileName				= "image.jpg";		// value user specified
    @@ -295,8 +306,9 @@ struct config {			// for configuration variables
     	char const *locale					= NULL;
     	long debugLevel						= 1;
     	bool consistentDelays				= true;
    -	bool videoOffBetweenImages			= true;
     	char const *ASIversion				= "UNKNOWN";		// calculated value
    +	bool videoOffBetweenImages			= true;
    +	ZWOexposure ZWOexposureType			= ZWOsnap;
     
     	struct overlay overlay;
     	struct myModeMeanSetting myModeMeanSetting;
    @@ -313,14 +325,12 @@ struct config {			// for configuration variables
     	double defaultSaturation			= NOT_SET;
     	double defaultContrast				= NOT_SET;
     	double defaultSharpness				= NOT_SET;
    -	long defaultBrightness				= NOT_SET;
     	int defaultQuality					= NOT_SET;
     
     	// Current values - may vary between day and night
     	bool currentAutoExposure;
     	long currentMaxAutoExposure_us;
     	long currentExposure_us;
    -	long currentBrightness;
     	int currentDelay_ms;
     	bool currentAutoGain;
     	double currentMaxAutoGain;
    @@ -342,7 +352,6 @@ struct config {			// for configuration variables
     	long lastFocusMetric				= NOT_SET;
     	long lastAsiBandwidth				= NOT_SET;
     	double lastMean						= NOT_SET;
    -	double lastMeanFull					= NOT_SET;
     	bool goodLastExposure				= false;		// Was the last image propery exposed?
     };
     
    @@ -366,7 +375,7 @@ std::string exec(const char *);
     void add_variables_to_command(config, char *, timeval);
     bool checkForValidExtension(config *);
     std::string calculateDayOrNight(const char *, const char *, float);
    -int calculateTimeToNightTime(const char *, const char *, float);
    +int calculateTimeToNextTime(const char *, const char *, float, bool);
     void Log(int, const char *, ...);
     char const *c(char const *);
     void closeUp(int);
    @@ -379,15 +388,21 @@ char const *getFlip(int);
     void closeUp(int);
     void IntHandle(int);
     int stopVideoCapture(int);
    +int stopExposure(int);
    +int closeCamera(int);
     bool validateLong(long *, long, long, char const *, bool);
     bool validateFloat(double *, double, double, char const *, bool);
     void displayHeader(config);
     void displayHelp(config);
     void displaySettings(config);
     char *LorF(double, char const *, char const *);
    -bool daytimeSleep(bool, config);
    +bool day_night_timeSleep(bool, config, bool);
     void delayBetweenImages(config, long, std::string);
    -bool getCommandLineArguments(config *, int, char *[]);
    +bool getCommandLineArguments(config *, int, char *[], bool);
    +bool getConfigFileArguments(config *);
     int displayNotificationImage(char const *);
     bool validateLatitudeLongitude(config *);
     void doLocale(config *);
    +char const *getZWOexposureType(ZWOexposure);
    +char *getLine(char *);
    +char *readFileIntoBuffer(config *, const char *);
    diff --git a/src/keogram.cpp b/src/keogram.cpp
    index 24c990239..618a3b2a8 100644
    --- a/src/keogram.cpp
    +++ b/src/keogram.cpp
    @@ -4,6 +4,11 @@
     // Rotation added by Agustin Nunez @agnunez
     // SPDX-License-Identifier: MIT
     
    +// These tell the invoker to not try again with these images
    +// since the next command will have the same problem.
    +#define NO_IMAGES			98
    +#define BAD_SAMPLE_FILE		99
    +
     using namespace std;
     
     #include <getopt.h>
    @@ -64,17 +69,23 @@ std::mutex stdio_mutex;
     int nchan = 0;
     unsigned long nfiles = 0;
     int s_len = 0;	// length in characters of nfiles, e.g. if nfiles == "1000", s_len = 4.
    +const char *ME = "";
     
     // Read a single file and return true on success and false on error.
    -// On success, set "mat".
    -bool read_file(struct config_t* cf, char* filename, cv::Mat* mat, int file_num, char *msg, int msg_size)
    +bool read_file(
    +		struct config_t* cf,
    +		char* filename,
    +		cv::Mat* mat,
    +		int file_num,
    +		char *msg,
    +		int msg_size)
     {
     	*mat = cv::imread(filename, cv::IMREAD_UNCHANGED);
    +
     	if (! mat->data || mat->empty()) {
     		if (cf->verbose) {
     			stdio_mutex.lock();
    -			fprintf(stderr, "Error reading file '%s': no data\n", filename);
    -			std::cout << "Error reading file " << filename << ": no data" << std::endl;
    +			std::cerr << "Error reading file " << filename << ": no data" << std::endl;
     			stdio_mutex.unlock();
     		}
     		return(false);
    @@ -93,8 +104,9 @@ bool read_file(struct config_t* cf, char* filename, cv::Mat* mat, int file_num,
     		}
     		return(false);
     	}
    +
     	if (cf->img_height && cf->img_width &&
    -		(mat->cols != cf->img_width || mat->rows != cf->img_height)) {
    +			(mat->cols != cf->img_width || mat->rows != cf->img_height)) {
     		if (cf->verbose) {
     			stdio_mutex.lock();
     			fprintf(stderr, "%s: image size %dx%d does not match expected size %dx%d; ignoring\n",
    @@ -117,13 +129,14 @@ char s_[10];
     const int num_hours = 24;
     bool hours[num_hours];
     
    -void keogram_worker(int thread_num,			// thread num
    -					struct config_t* cf,	// config
    -					glob_t* files,			// file list
    -					std::mutex* mtx,		// mutex
    -					cv::Mat* acc,			// accumulated
    -					cv::Mat* ann,			// annotations
    -					cv::Mat* mask)			// mask
    +void keogram_worker(
    +		int thread_num,			// thread num
    +		struct config_t* cf,	// config
    +		glob_t* files,			// file list
    +		std::mutex* mtx,		// mutex
    +		cv::Mat* acc,			// accumulated
    +		cv::Mat* ann,			// annotations
    +		cv::Mat* mask)			// mask
     {
     	int start_num, end_num, batch_size, prevHour = -1;
     	cv::Mat thread_accumulator;
    @@ -225,7 +238,8 @@ void keogram_worker(int thread_num,			// thread num
     			try {
     				imagesrc.col(imagesrc.cols / 2).copyTo(acc->col(destCol+i));	 //copy
     			} catch (cv::Exception& ex) {
    -				fprintf(stderr, "WARNING: internal copy of '%s' failed; ignoring\n", filename);
    +				fprintf(stderr, "%s: WARNING: internal copy of '%s' failed; ignoring\n",
    +						ME, filename);
     				continue;
     			}
     		}
    @@ -233,9 +247,9 @@ void keogram_worker(int thread_num,			// thread num
     		if (cf->labels_enabled) {
     			struct tm ft;	// the time of the file, by any means necessary
     			if (cf->parse_filename) {
    -				// engage your safety squints!
     				char* s;
     				// TODO: make sure strrchr() and sscanf work
    +
     				// Example of name:  image-yyyymmddhhmmss.jpg
     				s = strrchr(filename, '-');
     				s++;
    @@ -251,7 +265,8 @@ void keogram_worker(int thread_num,			// thread num
     					ft.tm_mon = t->tm_mon +1;
     					ft.tm_year = t->tm_year+1900;
     				} else {
    -					fprintf(stderr, "WARNING: unable to get time of '%s': %s\n", filename, strerror(errno));
    +					fprintf(stderr, "%s: WARNING: unable to get time of '%s': %s\n",
    +						ME, filename, strerror(errno));
     					ft.tm_hour = -1;
     				}
     			}
    @@ -359,7 +374,8 @@ void keogram_worker(int thread_num,			// thread num
     	}
     }
     
    -void annotate_image(cv::Mat* ann, cv::Mat* acc, struct config_t* cf) {
    +void annotate_image(cv::Mat* ann, cv::Mat* acc, struct config_t* cf)
    +{
     	int baseline = 0;
     	char hour[3];
     
    @@ -415,7 +431,8 @@ void annotate_image(cv::Mat* ann, cv::Mat* acc, struct config_t* cf) {
     					cv::Scalar(cf->b, cf->g, cf->r), cf->lineWidth,
     					cf->fontType);
     			} else if (cf->verbose) {
    -				fprintf(stderr, "WARNING: not enough space to print date '%s' at column %d\n", text.c_str(), column);
    +				fprintf(stderr, "WARNING: not enough space to print date '%s' at column %d\n",
    +					text.c_str(), column);
     			}
     		}
     
    @@ -455,13 +472,15 @@ void annotate_image(cv::Mat* ann, cv::Mat* acc, struct config_t* cf) {
     					cv::Scalar(cf->b, cf->g, cf->r), cf->lineWidth,
     					cf->fontType);
     			} else if (cf->verbose) {
    -				fprintf(stderr, "WARNING: not enough space to print hour label '%s' at column %d\n", text.c_str(), column);
    +				fprintf(stderr, "WARNING: not enough space to print hour label '%s' at column %d\n",
    +					text.c_str(), column);
     			}
     		}
     	}
     }
     
    -void parse_args(int argc, char** argv, struct config_t* cf) {
    +void parse_args(int argc, char** argv, struct config_t* cf)
    +{
     	int c, tmp, ncpu = std::thread::hardware_concurrency();
     
     	cf->labels_enabled = true;
    @@ -552,8 +571,17 @@ void parse_args(int argc, char** argv, struct config_t* cf) {
     				int height, width;
     				sscanf(optarg, "%dx%d", &width, &height);
     				// 122.8Mpx should be enough for anybody.
    -				if (height < 0 || height > 9600 || width < 0 || width > 12800)
    +				if (height < 0 || width < 0) {
    +					height = width = 0;
    +					fprintf(stderr, "%s: WARNING: height (%d) and width (%d) must be >= 0.",
    +						ME, height, width);
    +					fprintf(stderr, "  Setting to 0.\n");
    +				} else if (height > 9600 || width > 12800) {
    +					fprintf(stderr, "%s: WARNING: maximum height (%d) is 9600 and maximum width (%d) is 12800.",
    +						ME, height, width);
    +					fprintf(stderr, "  Setting to 0.\n");
     					height = width = 0;
    +				}
     				cf->img_height = height;
     				cf->img_width = width;
     				break;
    @@ -584,7 +612,7 @@ void parse_args(int argc, char** argv, struct config_t* cf) {
     				cf->lineWidth = atoi(optarg);
     				if (cf->lineWidth < 1) {
     					cf->lineWidth = 1;
    -					fprintf(stderr, "font-line changed to 1 (=minimum)\n");
    +					fprintf(stderr, "%s: font-line changed to 1 (=minimum)\n", ME);
     				}				
     				break;
     			case 'N':
    @@ -607,15 +635,18 @@ void parse_args(int argc, char** argv, struct config_t* cf) {
     				if ((tmp >= 1) && (tmp <= ncpu))
     					cf->num_threads = tmp;
     				else
    -					fprintf(stderr, "WARNING: invalid number of threads %d; using %d\n", tmp, cf->num_threads);
    +					fprintf(stderr, "%s: WARNING: invalid number of threads %d; using %d\n",
    +						ME, tmp, cf->num_threads);
     				break;
     			case 'q':
     				tmp = atoi(optarg);
     				if (PRIO_MIN > tmp) {
     					tmp = PRIO_MIN;
    -					fprintf(stderr, "WARNING: clamping scheduler priority to PRIO_MIN (%d)\n", PRIO_MIN);
    +					fprintf(stderr, "%s: WARNING: clamping scheduler priority to PRIO_MIN (%d)\n",
    +						ME, PRIO_MIN);
     				} else if (PRIO_MAX < tmp) {
    -					fprintf(stderr, "WARNING: clamping scheduler priority to PRIO_MAX (%d)\n", PRIO_MAX);
    +					fprintf(stderr, "%s: WARNING: clamping scheduler priority to PRIO_MAX (%d)\n",
    +						ME, PRIO_MAX);
     					tmp = PRIO_MAX;
     				}
     				cf->nice_level = atoi(optarg);
    @@ -627,46 +658,50 @@ void parse_args(int argc, char** argv, struct config_t* cf) {
     }
     
     void usage_and_exit(int x) {
    -	std::cout << "Usage:\tkeogram -d <imagedir> -e <ext> -o <outputfile> [<other_args>]" << std::endl;
    +	std::cerr << "Usage: " << ME << " -d <imagedir> -e <ext>"
    +		<< " -o <outputfile> [<other_args>]" << std::endl;
     	if (x)
    -		std::cout << KRED << "Source directory, image extension, and output file are required" << std::endl;
    -
    -	std::cout << KNRM << std::endl;
    -	std::cout << "Arguments:" << std::endl;
    -	std::cout << "-d | --directory <str> : directory from which to load images (required)" << std::endl;
    -	std::cout << "-e | --extension <str> : image extension to process (required)" << std::endl;
    -	std::cout << "-o | --output-file <str> : name of output file (required)" << std::endl;
    -	std::cout << "-r | --rotate <float> : number of degrees to rotate image, counterclockwise (0)" << std::endl;
    -	std::cout << "-s | --image-size <int>x<int> : only process images of a given size, eg. 1280x960" << std::endl;
    -	std::cout << "-h | --help : display this help message" << std::endl;
    -	std::cout << "-v | --verbose : Increase logging verbosity" << std::endl;
    -	std::cout << "-n | --no-label : Disable hour and date labels" << std::endl;
    -	std::cout << "-D | --no-date : Disable date label" << std::endl;
    -	std::cout << "-C | --font-color <str> : label font color, in HTML format (0000ff)" << std::endl;
    -	std::cout << "-L | --font-line <int> : font line thickness (3), (min=1)" << std::endl;
    -	std::cout << "-N | --font-name <str> : font name (simplex)" << std::endl;
    -	std::cout << "-S | --font-size <float> : font size (2.0)" << std::endl;
    -	std::cout << "-T | --font-type <int> : font line type (1)" << std::endl;
    -	std::cout << "-Q | --max-threads <int> : limit maximum number of processing threads. (use all cpus)" << std::endl;
    -	std::cout << "-q | --nice-level <int> : nice(2) level of processing threads (10)" << std::endl;
    -	std::cout << "-x | --image-expand : expand image to get the proportions of source" << std::endl;
    -	std::cout << "-c | --channel-info : show channel infos - mean value of R/G/B" << std::endl;
    -	std::cout << "-f | --fixed-channel-number <int> : define number of channels 0=auto, 1=mono, 3=rgb (0=auto)" << std::endl;
    -	std::cout << "-p | --parse-filename : parse time using filename instead of stat(filename)" << std::endl;
    -
    -	std::cout << KNRM << std::endl;
    -	std::cout << "Font name is one of these OpenCV font names:\n\tSimplex, Plain, "
    +		std::cerr << KRED << "Source directory, image extension, and output file are required" << std::endl;
    +
    +	std::cerr << KNRM << std::endl;
    +	std::cerr << "Arguments:" << std::endl;
    +	std::cerr << "-d | --directory <str> : directory from which to load images (required)" << std::endl;
    +	std::cerr << "-e | --extension <str> : image extension to process (required)" << std::endl;
    +	std::cerr << "-o | --output-file <str> : name of output file (required)" << std::endl;
    +	std::cerr << "-r | --rotate <float> : number of degrees to rotate image, counterclockwise (0)" << std::endl;
    +	std::cerr << "-s | --image-size <int>x<int> : only process images of a given size, eg. 1280x960" << std::endl;
    +	std::cerr << "-h | --help : display this help message" << std::endl;
    +	std::cerr << "-v | --verbose : Increase logging verbosity" << std::endl;
    +	std::cerr << "-n | --no-label : Disable hour and date labels" << std::endl;
    +	std::cerr << "-D | --no-date : Disable date label" << std::endl;
    +	std::cerr << "-C | --font-color <str> : label font color, in HTML format (0000ff)" << std::endl;
    +	std::cerr << "-L | --font-line <int> : font line thickness (3), (min=1)" << std::endl;
    +	std::cerr << "-N | --font-name <str> : font name (simplex)" << std::endl;
    +	std::cerr << "-S | --font-size <float> : font size (2.0)" << std::endl;
    +	std::cerr << "-T | --font-type <int> : font line type (1)" << std::endl;
    +	std::cerr << "-Q | --max-threads <int> : limit maximum number of processing threads. (use all cpus)" << std::endl;
    +	std::cerr << "-q | --nice-level <int> : nice(2) level of processing threads (10)" << std::endl;
    +	std::cerr << "-x | --image-expand : expand image to get the proportions of source" << std::endl;
    +	std::cerr << "-c | --channel-info : show channel infos - mean value of R/G/B" << std::endl;
    +	std::cerr << "-f | --fixed-channel-number <int> : define number of channels 0=auto, 1=mono, 3=rgb (0=auto)" << std::endl;
    +	std::cerr << "-p | --parse-filename : parse time using filename instead of stat(filename)" << std::endl;
    +
    +	std::cerr << KNRM << std::endl;
    +	std::cerr << "Font name is one of these OpenCV font names:\n\tSimplex, Plain, "
     	"Duplex, Complex, Triplex, ComplexSmall, ScriptSimplex, ScriptComplex" << std::endl;
    -	std::cout << "Font Type is an OpenCV line type: 0=antialias, 1=8-connected, 2=4-connected" << std::endl;
    -	std::cout << KNRM << std::endl;
    -	std::cout << "In some cases --font-line and --font-size can lead to annoying horizontal lines. Solution: try other values" << std::endl;
    -	std::cout << KNRM << std::endl;
    -	std::cout << "	ex: keogram --directory ../images/current/ --extension jpg --output-file keogram.jpg --font-size 2" << std::endl;
    -	std::cout << "	ex: keogram -d . -e png -o /home/pi/allsky/keogram.jpg -n" << KNRM << std::endl;
    +	std::cerr << "Font Type is an OpenCV line type: 0=antialias, 1=8-connected, 2=4-connected" << std::endl;
    +//x	std::cerr << KNRM << std::endl;
    +	std::cerr << "In some cases --font-line and --font-size can lead to annoying horizontal lines.:"
    +		<< std::endl
    +		<< "Solution: try other values" << std::endl;
    +//x	std::cerr << KNRM << std::endl;
    +	std::cerr << "   ex: keogram --directory ../images/current/ --extension jpg --output-file keogram.jpg --font-size 2" << std::endl;
    +	std::cerr << "   ex: keogram -d . -e png -o /home/pi/allsky/keogram.jpg -n" << KNRM << std::endl;
     	exit(x);
     }
     
    -int get_font_by_name(char* s) {
    +int get_font_by_name(char* s)
    +{
     	// case insensitively check the user-specified font, and use something
     	// sensible in case of erroneous input
     	if (strcasecmp(s, "plain") == 0)
    @@ -684,14 +719,16 @@ int get_font_by_name(char* s) {
     	if (strcasecmp(s, "scriptcomplex") == 0)
     		return cv::FONT_HERSHEY_SCRIPT_COMPLEX;
     	if (strcasecmp(s, "simplex"))	// yes, this is intentional
    -		std::cout << KRED << "Unknown font '" << s << "', using SIMPLEX " << KNRM << std::endl;
    +		std::cerr << KRED << ME << ": Unknown font '" << s << "', using SIMPLEX " << KNRM << std::endl;
     
     	return cv::FONT_HERSHEY_SIMPLEX;
     }
     
    -int main(int argc, char* argv[]) {
    +int main(int argc, char* argv[])
    +{
     	struct config_t config;
     	int r;
    +	ME = basename(argv[0]);
     
     	parse_args(argc, argv, &config);
     
    @@ -701,7 +738,7 @@ int main(int argc, char* argv[]) {
     	r = setpriority(PRIO_PROCESS, 0, config.nice_level);
     	if (r) {
     		config.nice_level = getpriority(PRIO_PROCESS, 0);
    -		fprintf(stderr, "unable to set nice level: %s\n", strerror(errno));
    +		fprintf(stderr, "%s: WARNING: unable to set nice level to %s; ignoring\n", ME, strerror(errno));
     	}
     
     	// Find files
    @@ -711,8 +748,9 @@ int main(int argc, char* argv[]) {
     	nfiles = files.gl_pathc;
     	if (nfiles == 0) {
     		globfree(&files);
    -		std::cout << "No images found, exiting." << std::endl;
    -		exit(1);
    +		std::cerr << ME << ": ERROR: No images found in " << config.img_src_dir;
    +		std::cerr << ", exiting." << std::endl;
    +		exit(NO_IMAGES);
     	}
     	// Determine width of the number of files, e.g., "1234" is 4 characters wide.
     	sprintf(s_, "%d", (int)nfiles);
    @@ -736,8 +774,8 @@ int main(int argc, char* argv[]) {
     	if (nchan == 0 || (config.img_width == 0 && config.img_height == 0)) {
     		char not_used[1];
     		if (! read_file(&config, sample_file, &temp, sample_file_num+1, not_used, 0)) {
    -			fprintf(stderr, "ERROR: Unable to read sample file '%s'; quitting\n", sample_file);
    -			exit(1);
    +			fprintf(stderr, "%s: ERROR: Unable to read sample file '%s'; quitting\n", ME, sample_file);
    +			exit(BAD_SAMPLE_FILE);
     		}
     		if (config.verbose > 1) {
     			fprintf(stderr, "Getting nchan and/or size from: '%s'\n", sample_file);
    @@ -799,11 +837,13 @@ int main(int argc, char* argv[]) {
     	try {
     		result = cv::imwrite(config.dst_keogram, accumulated, compression_params);
     	} catch (cv::Exception& ex) {
    -		fprintf(stderr, "ERROR: could not save keogram file: %s\n", ex.what());
    +		// Use lowercase "k"eogram here and uppercase below so we
    +		// know what produced the error.
    +		fprintf(stderr, "%s: ERROR: could not save keogram file: %s\n", ME, ex.what());
     		exit(2);
     	}
     	if (! result) {
    -		fprintf(stderr, "ERROR: could not save Keogram file: %s\n", strerror(errno));
    +		fprintf(stderr, "%s: ERROR: could not save Keogram file: %s\n", ME, strerror(errno));
     		exit(2);
     	}
     
    diff --git a/src/lib/armv6/libASICamera2.a b/src/lib/armv6/libASICamera2.a
    index 49cd93b97..c72874c91 100644
    Binary files a/src/lib/armv6/libASICamera2.a and b/src/lib/armv6/libASICamera2.a differ
    diff --git a/src/lib/armv7/libASICamera2.a b/src/lib/armv7/libASICamera2.a
    index df2f89609..351669942 100644
    Binary files a/src/lib/armv7/libASICamera2.a and b/src/lib/armv7/libASICamera2.a differ
    diff --git a/src/lib/armv8/libASICamera2.a b/src/lib/armv8/libASICamera2.a
    index 89cdd64bc..5307bbfa6 100644
    Binary files a/src/lib/armv8/libASICamera2.a and b/src/lib/armv8/libASICamera2.a differ
    diff --git a/src/lib/x64/libASICamera2.a b/src/lib/x64/libASICamera2.a
    index 9b95ef06a..f0abc024d 100644
    Binary files a/src/lib/x64/libASICamera2.a and b/src/lib/x64/libASICamera2.a differ
    diff --git a/src/lib/x86/libASICamera2.a b/src/lib/x86/libASICamera2.a
    index c7f6976ee..cbc2ce9cc 100644
    Binary files a/src/lib/x86/libASICamera2.a and b/src/lib/x86/libASICamera2.a differ
    diff --git a/src/mode_mean.cpp b/src/mode_mean.cpp
    index 8d163fa1f..32e94527a 100644
    --- a/src/mode_mean.cpp
    +++ b/src/mode_mean.cpp
    @@ -122,13 +122,26 @@ float aegCalcMean(cv::Mat image, bool useMask)
     	// Only create the destination image and mask the first time we're called.
     	static cv::Mat mask;
     	static bool maskCreated = false;
    +
    +	if (image.cols != mask.cols || image.rows != mask.rows)
    +	{
    +		// If the image size changed we need a new mask or else we get a cv::exception.
    +		maskCreated = false;
    +		if (mask.rows > 0)
    +		{
    +			mask.release();
    +		}
    +	}
    +
     	if (! maskCreated)
     	{
     		maskCreated = true;
     
    +		Log(4, ">=>= Creating new mask @ cols=%d, rows=%d\n", image.cols, image.rows);
    +
     // TODO: Allow user to specify a mask file
     
    -		// Create a circular mask at the center of the image with
    +		// Create a white circular mask at the center of the image with
     		// a radius of 1/3 the height of the image (diameter == 2/3 height).
     
     		const cv::Scalar white = cv::Scalar(255, 255, 255);
    diff --git a/src/startrails.cpp b/src/startrails.cpp
    index 5d4f08ba1..b2dcffce7 100644
    --- a/src/startrails.cpp
    +++ b/src/startrails.cpp
    @@ -3,6 +3,11 @@
     // Based on script by Thomas Jacquin
     // SPDX-License-Identifier: MIT
     
    +// These tell the invoker to not try again with these images
    +// since the next command will have the same problem.
    +#define NO_IMAGES			98
    +#define BAD_SAMPLE_FILE		99
    +
     using namespace std;
     
     #include <getopt.h>
    @@ -48,10 +53,17 @@ std::mutex stdio_mutex;
     int nchan = 0;
     unsigned long nfiles = 0;
     int s_len = 0;	// length in characters of nfiles, e.g. if nfiles == "1000", s_len = 4.
    +const char *ME = "";
     
     // Read a single file and return true on success and false on error.
     // On success, set "mat".
    -bool read_file(struct config_t* cf, char* filename, cv::Mat* mat, int file_num, char *msg, int msg_size)
    +bool read_file(
    +		struct config_t* cf,
    +		char* filename,
    +		cv::Mat* mat,
    +		int file_num,
    +		char *msg,
    +		int msg_size)
     {
     	*mat = cv::imread(filename, cv::IMREAD_UNCHANGED);
     	if (! mat->data || mat->empty()) {
    @@ -96,12 +108,13 @@ void usage_and_exit(int);
     // Keep track of number of digits in nfiles so file numbers will be consistent width.
     char s_[10];
     
    -void startrail_worker(int thread_num,				// thread num
    -					  struct config_t* cf,			// config
    -					  glob_t* files,				// file list
    -					  std::mutex* mtx,				// mutex
    -					  cv::Mat* stats_ptr,			// statistics
    -					  cv::Mat* main_accumulator)	// accumulated
    +void startrail_worker(
    +		int thread_num,				// thread num
    +		struct config_t* cf,			// config
    +		glob_t* files,				// file list
    +		std::mutex* mtx,				// mutex
    +		cv::Mat* stats_ptr,			// statistics
    +		cv::Mat* main_accumulator)	// accumulated
     {
     	int start_num, end_num, batch_size;
     	cv::Mat thread_accumulator;
    @@ -139,7 +152,8 @@ void startrail_worker(int thread_num,				// thread num
     		repair_msg[0] = '\0';
     		if (imagesrc.channels() != nchan) {
     			if (cf->verbose) {
    -				snprintf(repair_msg, repair_msg_size, "%s: repairing channel mismatch from %d to %d\n", filename, imagesrc.channels(), nchan);
    +				snprintf(repair_msg, repair_msg_size, "%s: repairing channel mismatch from %d to %d\n",
    +					filename, imagesrc.channels(), nchan);
     			}
     			if (imagesrc.channels() < nchan)
     				cv::cvtColor(imagesrc, imagesrc, cv::COLOR_GRAY2BGR, nchan);
    @@ -207,7 +221,8 @@ void startrail_worker(int thread_num,				// thread num
     	}
     }
     
    -void parse_args(int argc, char** argv, struct config_t* cf) {
    +void parse_args(int argc, char** argv, struct config_t* cf)
    +{
     	int c, tmp, ncpu = std::thread::hardware_concurrency();
     
     	cf->verbose = 0;
    @@ -252,8 +267,17 @@ void parse_args(int argc, char** argv, struct config_t* cf) {
     				int height, width;
     				sscanf(optarg, "%dx%d", &width, &height);
     				// 122.8Mpx should be enough for anybody.
    -				if (height < 0 || height > 9600 || width < 0 || width > 12800)
    +				if (height < 0 || width < 0) {
     					height = width = 0;
    +					fprintf(stderr, "%s: WARNING: height (%d) and width (%d) must be >= 0.",
    +						ME, height, width);
    +					fprintf(stderr, "  Setting to 0.\n");
    +				} else if (height > 9600 || width > 12800) {
    +					fprintf(stderr, "%s: WARNING: maximum height (%d) is 9600 and maximum width (%d) is 12800.",
    +						ME, height, width);
    +					fprintf(stderr, "  Setting to 0.\n");
    +					height = width = 0;
    +				}
     				cf->img_height = height;
     				cf->img_width = width;
     				break;
    @@ -278,15 +302,18 @@ void parse_args(int argc, char** argv, struct config_t* cf) {
     				if ((tmp >= 1) && (tmp <= ncpu))
     					cf->num_threads = tmp;
     				else
    -					fprintf(stderr, "WARNING: Invalid number of threads %d; using %d\n", tmp, cf->num_threads);
    +					fprintf(stderr, "%s: WARNING: Invalid number of threads %d; using %d\n",
    +						ME, tmp, cf->num_threads);
     				break;
     			case 'q':
     				tmp = atoi(optarg);
     				if (PRIO_MIN > tmp) {
     					tmp = PRIO_MIN;
    -					fprintf(stderr, "WARNING: Clamping scheduler priority to PRIO_MIN (%d)\n", PRIO_MIN);
    +					fprintf(stderr, "%s: WARNING: Clamping scheduler priority to PRIO_MIN (%d)\n",
    +						ME, PRIO_MIN);
     				} else if (PRIO_MAX < tmp) {
    -					fprintf(stderr, "WARNING: Clamping scheduler priority to PRIO_MAX (%d)\n", PRIO_MAX);
    +					fprintf(stderr, "%s: WARNING: Clamping scheduler priority to PRIO_MAX (%d)\n",
    +						ME, PRIO_MAX);
     					tmp = PRIO_MAX;
     				}
     				cf->nice_level = atoi(optarg);
    @@ -301,36 +328,38 @@ void parse_args(int argc, char** argv, struct config_t* cf) {
     }
     
     void usage_and_exit(int x) {
    -	std::cout << "Usage: startrails [-v] -d <dir> -e <ext> [-b <brightness> -o <output> | -S] "
    -		" [-s <WxH>] [-Q <max-threads>] [-q <nice>]" << std::endl;
    +	std::cerr << "Usage: " << ME << " [-v] -d <imagedir> -e <ext>"
    +		<< " [-b <brightness> -o <output> | -S] "
    +		<< " [-s <WxH>] [-Q <max-threads>] [-q <nice>]" << std::endl;
     	if (x) {
    -		std::cout << KRED
    +		std::cerr << KRED
     			<< "Source directory and file extension are always required." << std::endl
     			<< "Brightness threshold and output file are required to render startrails."
     			<< KNRM << std::endl;
     	}
     
    -	std::cout << std::endl << "Arguments:" << std::endl;
    -	std::cout << "-h | --help : display this help, then exit" << std::endl;
    -	std::cout << "-v | --verbose : increase log verbosity" << std::endl;
    -	std::cout << "-S | --statistics : print image directory statistics without producing image" << std::endl;
    -	std::cout << "-d | --directory <str> : directory from which to read images" << std::endl;
    -	std::cout << "-e | --extension <str> : filter images to just this extension" << std::endl;
    -	std::cout << "-Q | --max-threads <int> : limit maximum number of processing threads (all cpus)" << std::endl;
    -	std::cout << "-q | --nice <int> : nice(2) level of processing threads (10)" << std::endl;
    -	std::cout << "-o | --output-file <str> : output image filename" << std::endl;
    -	std::cout << "-s | --image-size <int>x<int> : restrict processed images to this size" << std::endl;
    -	std::cout << "-b | --brightness-limit <float> : ranges from 0 (black) to 1 (white). (0.35)" << std::endl;
    -	std::cout << "\tA moonless sky may be as low as 0.05 while full moon can be as high as 0.4" << std::endl;
    -
    -	std::cout << std::endl;
    -	std::cout << "ex: startrails -b 0.07 -d ../images/20220710/ -e jpg -o startrails.jpg" << std::endl;
    +	std::cerr << std::endl << "Arguments:" << std::endl;
    +	std::cerr << "-h | --help : display this help, then exit" << std::endl;
    +	std::cerr << "-v | --verbose : increase log verbosity" << std::endl;
    +	std::cerr << "-S | --statistics : print image directory statistics without producing image" << std::endl;
    +	std::cerr << "-d | --directory <str> : directory from which to read images" << std::endl;
    +	std::cerr << "-e | --extension <str> : filter images to just this extension" << std::endl;
    +	std::cerr << "-Q | --max-threads <int> : limit maximum number of processing threads (all cpus)" << std::endl;
    +	std::cerr << "-q | --nice <int> : nice(2) level of processing threads (10)" << std::endl;
    +	std::cerr << "-o | --output-file <str> : output image filename" << std::endl;
    +	std::cerr << "-s | --image-size <int>x<int> : restrict processed images to this size" << std::endl;
    +	std::cerr << "-b | --brightness-limit <float> : ranges from 0 (black) to 1 (white). (0.35)" << std::endl;
    +	std::cerr << "\tA moonless sky may be as low as 0.05 while full moon can be as high as 0.4" << std::endl;
    +
    +	std::cerr << std::endl;
    +	std::cerr << "ex: startrails -b 0.07 -d ../images/20240710/ -e jpg -o startrails.jpg" << std::endl;
     	exit(x);
     }
     
     int main(int argc, char* argv[]) {
     	struct config_t config;
     	int r;
    +	ME = basename(argv[0]);
     
     	parse_args(argc, argv, &config);
     
    @@ -367,8 +396,9 @@ int main(int argc, char* argv[]) {
     	nfiles = files.gl_pathc;
     	if (nfiles == 0) {
     		globfree(&files);
    -		std::cout << "ERROR: No images found, exiting." << std::endl;
    -		exit(1);
    +		std::cerr << ME << ": ERROR: No images found in " << config.img_src_dir;
    +		std::cerr << ", exiting." << std::endl;
    +		exit(NO_IMAGES);
     	}
     	// Determine width of the number of files, e.g., "1234" is 4 characters wide.
     	sprintf(s_, "%d", (int)nfiles);
    @@ -395,8 +425,8 @@ int main(int argc, char* argv[]) {
     	if (nchan == 0 || (config.img_width == 0 && config.img_height == 0)) {
     		char not_used[1];
     		if (! read_file(&config, sample_file, &temp, sample_file_num+1, not_used, 0)) {
    -			fprintf(stderr, "ERROR: Unable to read sample file '%s'; quitting\n", sample_file);
    -			exit(1);
    +			fprintf(stderr, "%s: ERROR: Unable to read sample file '%s'; quitting\n", ME, sample_file);
    +			exit(BAD_SAMPLE_FILE);
     		}
     		if (config.verbose > 1) {
     			fprintf(stderr, "Getting nchan and/or size from: '%s'\n", sample_file);
    @@ -447,14 +477,15 @@ int main(int argc, char* argv[]) {
     	std::nth_element(vec.begin(), vec.begin() + (vec.size() / 2), vec.end());
     	ds_median = vec[vec.size() / 2];
     
    -	std::cout << "Minimum: " << ds_min << " maximum: " << ds_max
    -			<< " mean: " << ds_mean << " median: " << ds_median << std::endl;
    +	std::cout << ME << ": Minimum: " << ds_min << "   maximum: " << ds_max
    +			<< "   mean: " << ds_mean << "   median: " << ds_median << std::endl;
     
     	// If we still don't have an image (no images below threshold), copy the
     	// minimum mean image so we see why
     	if (config.startrails_enabled) {
     		if (accumulated.empty()) {
    -			fprintf(stderr, "No images below threshold %.3f, writing the minimum mean image only.\n",
    +			// Indent since this msg goes with the line above.
    +			fprintf(stderr, "    No images below threshold %.3f, writing the minimum mean image only.\n",
     					config.brightness_limit);
     			accumulated = cv::imread(files.gl_pathv[min_loc.x], cv::IMREAD_UNCHANGED);
     		}
    @@ -472,11 +503,13 @@ int main(int argc, char* argv[]) {
     		try {
     			result = cv::imwrite(config.dst_startrails, accumulated, compression_params);
     		} catch (cv::Exception& ex) {
    -			fprintf(stderr, "ERROR: could not save startrails file: %s\n", ex.what());
    +			// Use lowercase "s"tartrails here and uppercase below so we
    +			// know what produced the error.
    +			fprintf(stderr, "%s: ERROR: could not save startrails file: %s\n", ME, ex.what());
     			exit(2);
     		}
     		if (! result) {
    -			fprintf(stderr, "ERROR: could not save Startrails file: %s\n", strerror(errno));
    +			fprintf(stderr, "%s: ERROR: could not save Startrails file: %s\n", ME, strerror(errno));
     			exit(2);
     		}
     	}
    diff --git a/src/uhubctl.c b/src/uhubctl.c
    new file mode 100644
    index 000000000..56dae4f45
    --- /dev/null
    +++ b/src/uhubctl.c
    @@ -0,0 +1,1245 @@
    +/*
    + * Copyright (c) 2009-2023 Vadim Mikhailov
    + *
    + * Utility to turn USB port power on/off
    + * for USB hubs that support per-port power switching.
    + *
    + * This file can be distributed under the terms and conditions of the
    + * GNU General Public License version 2.
    + *
    + */
    +
    +#define _XOPEN_SOURCE 500
    +
    +#include <stdio.h>
    +#include <stdlib.h>
    +#include <string.h>
    +#include <strings.h>
    +#include <getopt.h>
    +#include <errno.h>
    +#include <ctype.h>
    +
    +#if defined(_WIN32)
    +#include <windows.h>
    +#include <io.h>
    +#include <process.h>
    +#define strcasecmp _stricmp
    +#define strncasecmp _strnicmp
    +#else
    +#include <unistd.h>
    +#endif
    +
    +#if defined(__FreeBSD__) || defined(__NetBSD__) || defined(_WIN32)
    +#include <libusb.h>
    +#else
    +#include <libusb-1.0/libusb.h>
    +#endif
    +
    +#if defined(__APPLE__) || defined(__FreeBSD__) /* snprintf is not available in pure C mode */
    +int snprintf(char * __restrict __str, size_t __size, const char * __restrict __format, ...) __printflike(3, 4);
    +#endif
    +
    +#if !defined(LIBUSB_API_VERSION) || (LIBUSB_API_VERSION <= 0x01000103)
    +#define LIBUSB_DT_SUPERSPEED_HUB 0x2a
    +#endif
    +
    +#if _POSIX_C_SOURCE >= 199309L
    +#include <time.h>   /* for nanosleep */
    +#endif
    +
    +#ifdef __gnu_linux__
    +#include <fcntl.h> /* for open() / O_WRONLY */
    +#endif
    +
    +/* cross-platform sleep function */
    +
    +void sleep_ms(int milliseconds)
    +{
    +#if defined(_WIN32)
    +    Sleep(milliseconds);
    +#elif _POSIX_C_SOURCE >= 199309L
    +    struct timespec ts;
    +    ts.tv_sec = milliseconds / 1000;
    +    ts.tv_nsec = (milliseconds % 1000) * 1000000;
    +    nanosleep(&ts, NULL);
    +#else
    +    usleep(milliseconds * 1000);
    +#endif
    +}
    +
    +/* Max number of hub ports supported */
    +#define MAX_HUB_PORTS            14
    +#define ALL_HUB_PORTS            ((1 << MAX_HUB_PORTS) - 1) /* bitmask */
    +
    +#define USB_CTRL_GET_TIMEOUT     5000
    +
    +#define USB_PORT_FEAT_POWER      (1 << 3)
    +
    +#define POWER_KEEP               (-1)
    +#define POWER_OFF                0
    +#define POWER_ON                 1
    +#define POWER_CYCLE              2
    +#define POWER_TOGGLE             3
    +
    +#define MAX_HUB_CHAIN            8  /* Per USB 3.0 spec max hub chain is 7 */
    +
    +/* Partially borrowed from linux/usb/ch11.h */
    +
    +#pragma pack(push,1)
    +struct usb_hub_descriptor {
    +    unsigned char bDescLength;
    +    unsigned char bDescriptorType;
    +    unsigned char bNbrPorts;
    +    unsigned char wHubCharacteristics[2];
    +    unsigned char bPwrOn2PwrGood;
    +    unsigned char bHubContrCurrent;
    +    unsigned char data[1]; /* use 1 to avoid zero-sized array warning */
    +};
    +#pragma pack(pop)
    +
    +/*
    + * Hub Status and Hub Change results
    + * See USB 2.0 spec Table 11-19 and Table 11-20
    + */
    +#pragma pack(push,1)
    +struct usb_port_status {
    +    int16_t wPortStatus;
    +    int16_t wPortChange;
    +};
    +#pragma pack(pop)
    +
    +/*
    + * wPortStatus bit field
    + * See USB 2.0 spec Table 11-21
    + */
    +#define USB_PORT_STAT_CONNECTION        0x0001
    +#define USB_PORT_STAT_ENABLE            0x0002
    +#define USB_PORT_STAT_SUSPEND           0x0004
    +#define USB_PORT_STAT_OVERCURRENT       0x0008
    +#define USB_PORT_STAT_RESET             0x0010
    +#define USB_PORT_STAT_L1                0x0020
    +/* bits 6 to 7 are reserved */
    +#define USB_PORT_STAT_POWER             0x0100
    +#define USB_PORT_STAT_LOW_SPEED         0x0200
    +#define USB_PORT_STAT_HIGH_SPEED        0x0400
    +#define USB_PORT_STAT_TEST              0x0800
    +#define USB_PORT_STAT_INDICATOR         0x1000
    +/* bits 13 to 15 are reserved */
    +
    +
    +#define USB_SS_BCD                      0x0300
    +/*
    + * Additions to wPortStatus bit field from USB 3.0
    + * See USB 3.0 spec Table 10-10
    + */
    +#define USB_PORT_STAT_LINK_STATE        0x01e0
    +#define USB_SS_PORT_STAT_POWER          0x0200
    +#define USB_SS_PORT_STAT_SPEED          0x1c00
    +#define USB_PORT_STAT_SPEED_5GBPS       0x0000
    +/* Valid only if port is enabled */
    +/* Bits that are the same from USB 2.0 */
    +#define USB_SS_PORT_STAT_MASK (USB_PORT_STAT_CONNECTION  | \
    +                               USB_PORT_STAT_ENABLE      | \
    +                               USB_PORT_STAT_OVERCURRENT | \
    +                               USB_PORT_STAT_RESET)
    +
    +/*
    + * Definitions for PORT_LINK_STATE values
    + * (bits 5-8) in wPortStatus
    + */
    +#define USB_SS_PORT_LS_U0               0x0000
    +#define USB_SS_PORT_LS_U1               0x0020
    +#define USB_SS_PORT_LS_U2               0x0040
    +#define USB_SS_PORT_LS_U3               0x0060
    +#define USB_SS_PORT_LS_SS_DISABLED      0x0080
    +#define USB_SS_PORT_LS_RX_DETECT        0x00a0
    +#define USB_SS_PORT_LS_SS_INACTIVE      0x00c0
    +#define USB_SS_PORT_LS_POLLING          0x00e0
    +#define USB_SS_PORT_LS_RECOVERY         0x0100
    +#define USB_SS_PORT_LS_HOT_RESET        0x0120
    +#define USB_SS_PORT_LS_COMP_MOD         0x0140
    +#define USB_SS_PORT_LS_LOOPBACK         0x0160
    +
    +
    +/*
    + * wHubCharacteristics (masks)
    + * See USB 2.0 spec Table 11-13, offset 3
    + */
    +#define HUB_CHAR_LPSM           0x0003 /* Logical Power Switching Mode mask */
    +#define HUB_CHAR_COMMON_LPSM    0x0000 /* All ports at once power switching */
    +#define HUB_CHAR_INDV_PORT_LPSM 0x0001 /* Per-port power switching */
    +#define HUB_CHAR_NO_LPSM        0x0002 /* No power switching */
    +
    +#define HUB_CHAR_COMPOUND       0x0004 /* hub is part of a compound device */
    +
    +#define HUB_CHAR_OCPM           0x0018 /* Over-Current Protection Mode mask */
    +#define HUB_CHAR_COMMON_OCPM    0x0000 /* All ports at once over-current protection */
    +#define HUB_CHAR_INDV_PORT_OCPM 0x0008 /* Per-port over-current protection */
    +#define HUB_CHAR_NO_OCPM        0x0010 /* No over-current protection support */
    +
    +#define HUB_CHAR_TTTT           0x0060 /* TT Think Time mask */
    +#define HUB_CHAR_PORTIND        0x0080 /* per-port indicators (LEDs) */
    +
    +/* List of all USB devices enumerated by libusb */
    +static struct libusb_device **usb_devs = NULL;
    +
    +struct descriptor_strings {
    +    char vendor[64];
    +    char product[64];
    +    char serial[64];
    +    char description[512];
    +};
    +
    +struct hub_info {
    +    struct libusb_device *dev;
    +    int bcd_usb;
    +    int super_speed; /* 1 if super speed hub, and 0 otherwise */
    +    int nports;
    +    int lpsm; /* logical power switching mode */
    +    int actionable; /* true if this hub is subject to action */
    +    char container_id[33]; /* container ID as hex string */
    +    char vendor[16];
    +    char location[32];
    +    uint8_t bus;
    +    uint8_t port_numbers[MAX_HUB_CHAIN];
    +    int pn_len; /* length of port numbers */
    +    struct descriptor_strings ds;
    +};
    +
    +/* Array of all enumerated USB hubs */
    +#define MAX_HUBS 128
    +static struct hub_info hubs[MAX_HUBS];
    +static int hub_count = 0;
    +static int hub_phys_count = 0;
    +
    +/* default options */
    +static char opt_vendor[16]   = "";
    +static char opt_search[64]   = "";     /* Search by attached device description */
    +static char opt_location[32] = "";     /* Hub location a-b.c.d */
    +static int opt_level = 0;              /* Hub location level (e.g., a-b is level 2, a-b.c is level 3)*/
    +static int opt_ports  = ALL_HUB_PORTS; /* Bitmask of ports to operate on */
    +static int opt_action = POWER_KEEP;
    +static double opt_delay = 2;
    +static int opt_repeat = 1;
    +static int opt_wait   = 20; /* wait before repeating in ms */
    +static int opt_exact  = 0;  /* exact location match - disable USB3 duality handling */
    +static int opt_reset  = 0;  /* reset hub after operation(s) */
    +static int opt_force  = 0;  /* force operation even on unsupported hubs */
    +static int opt_nodesc = 0;  /* skip querying device description */
    +#ifdef __gnu_linux__
    +static int opt_nosysfs = 0; /* don't use the Linux sysfs port disable interface, even if available */
    +#endif
    +
    +
    +static const char short_options[] =
    +    "l:L:n:a:p:d:r:w:s:hvefRN"
    +#ifdef __gnu_linux__
    +    "S"
    +#endif
    +;
    +
    +static const struct option long_options[] = {
    +    { "location", required_argument, NULL, 'l' },
    +    { "vendor",   required_argument, NULL, 'n' },
    +    { "search",   required_argument, NULL, 's' },
    +    { "level",    required_argument, NULL, 'L' },
    +    { "ports",    required_argument, NULL, 'p' },
    +    { "action",   required_argument, NULL, 'a' },
    +    { "delay",    required_argument, NULL, 'd' },
    +    { "repeat",   required_argument, NULL, 'r' },
    +    { "wait",     required_argument, NULL, 'w' },
    +    { "exact",    no_argument,       NULL, 'e' },
    +    { "force",    no_argument,       NULL, 'f' },
    +    { "nodesc",   no_argument,       NULL, 'N' },
    +#ifdef __gnu_linux__
    +    { "nosysfs",  no_argument,       NULL, 'S' },
    +#endif
    +    { "reset",    no_argument,       NULL, 'R' },
    +    { "version",  no_argument,       NULL, 'v' },
    +    { "help",     no_argument,       NULL, 'h' },
    +    { 0,          0,                 NULL, 0   },
    +};
    +
    +
    +static int print_usage(void)
    +{
    +    printf(
    +        "uhubctl: utility to control USB port power for smart hubs.\n"
    +        "Usage: uhubctl [options]\n"
    +        "Without options, show status for all smart hubs.\n"
    +        "\n"
    +        "Options [defaults in brackets]:\n"
    +        "--action,   -a - action to off/on/cycle/toggle (0/1/2/3) for affected ports.\n"
    +        "--ports,    -p - ports to operate on    [all hub ports].\n"
    +        "--location, -l - limit hub by location  [all smart hubs].\n"
    +        "--level     -L - limit hub by location level (e.g. a-b.c is level 3).\n"
    +        "--vendor,   -n - limit hub by vendor id [%s] (partial ok).\n"
    +        "--search,   -s - limit hub by attached device description.\n"
    +        "--delay,    -d - delay for cycle action [%g sec].\n"
    +        "--repeat,   -r - repeat power off count [%d] (some devices need it to turn off).\n"
    +        "--exact,    -e - exact location (no USB3 duality handling).\n"
    +        "--force,    -f - force operation even on unsupported hubs.\n"
    +        "--nodesc,   -N - do not query device description (helpful for unresponsive devices).\n"
    +#ifdef __gnu_linux__
    +        "--nosysfs,  -S - do not use the Linux sysfs port disable interface.\n"
    +#endif
    +        "--reset,    -R - reset hub after each power-on action, causing all devices to reassociate.\n"
    +        "--wait,     -w - wait before repeat power off [%d ms].\n"
    +        "--version,  -v - print program version.\n"
    +        "--help,     -h - print this text.\n"
    +        "\n"
    +        "Send bugs and requests to: https://github.com/mvp/uhubctl\n"
    +        "version: %s\n",
    +        strlen(opt_vendor) ? opt_vendor : "any",
    +        opt_delay,
    +        opt_repeat,
    +        opt_wait,
    +        PROGRAM_VERSION
    +    );
    +    return 0;
    +}
    +
    +
    +/* trim trailing spaces from a string */
    +
    +static char* rtrim(char* str)
    +{
    +    int i;
    +    for (i = strlen(str)-1; i>=0 && isspace(str[i]); i--) {
    +        str[i] = 0;
    +    }
    +    return str;
    +}
    +
    +/*
    + * Convert port list into bitmap.
    + * Following port list specifications are equivalent:
    + *   1,3,4,5,11,12,13
    + *   1,3-5,11-13
    + * Returns: bitmap of specified ports, max port is MAX_HUB_PORTS.
    + */
    +
    +static int ports2bitmap(char* const portlist)
    +{
    +    int ports = 0;
    +    char* position = portlist;
    +    char* comma;
    +    char* dash;
    +    int len;
    +    int i;
    +    while (position) {
    +        char buf[8] = {0};
    +        comma = strchr(position, ',');
    +        len = sizeof(buf) - 1;
    +        if (comma) {
    +            if (len > comma - position)
    +                len = comma - position;
    +            strncpy(buf, position, len);
    +            position = comma + 1;
    +        } else {
    +            strncpy(buf, position, len);
    +            position = NULL;
    +        }
    +        /* Check if we have port range, e.g.: a-b */
    +        int a=0, b=0;
    +        a = atoi(buf);
    +        dash = strchr(buf, '-');
    +        if (dash) {
    +            b = atoi(dash+1);
    +        } else {
    +            b = a;
    +        }
    +        if (a > b) {
    +            fprintf(stderr, "Bad port spec %d-%d, first port must be less than last\n", a, b);
    +            exit(1);
    +        }
    +        if (a <= 0 || a > MAX_HUB_PORTS || b <= 0 || b > MAX_HUB_PORTS) {
    +            fprintf(stderr, "Bad port spec %d-%d, port numbers must be from 1 to %d\n", a, b, MAX_HUB_PORTS);
    +            exit(1);
    +        }
    +        for (i=a; i<=b; i++) {
    +            ports |= (1 << (i-1));
    +        }
    +    }
    +    return ports;
    +}
    +
    +
    +/*
    + * Compatibility wrapper around libusb_get_port_numbers()
    + */
    +
    +static int get_port_numbers(libusb_device *dev, uint8_t *buf, uint8_t bufsize)
    +{
    +    int pcount;
    +#if defined(LIBUSB_API_VERSION) && (LIBUSB_API_VERSION >= 0x01000102)
    +    /*
    +     * libusb_get_port_path is deprecated since libusb v1.0.16,
    +     * therefore use libusb_get_port_numbers when supported
    +     */
    +    pcount = libusb_get_port_numbers(dev, buf, bufsize);
    +#else
    +    pcount = libusb_get_port_path(NULL, dev, buf, bufsize);
    +#endif
    +    return pcount;
    +}
    +
    +/*
    + * get USB hub properties.
    + * most hub_info fields are filled, except for description.
    + * returns 0 for success and error code for failure.
    + */
    +
    +static int get_hub_info(struct libusb_device *dev, struct hub_info *info)
    +{
    +    int rc = 0;
    +    int len = 0;
    +    struct libusb_device_handle *devh = NULL;
    +    unsigned char buf[LIBUSB_DT_HUB_NONVAR_SIZE + 2 + 3] = {0};
    +    struct usb_hub_descriptor *uhd = (struct usb_hub_descriptor *)buf;
    +    int minlen = LIBUSB_DT_HUB_NONVAR_SIZE + 2;
    +    struct libusb_device_descriptor desc;
    +    rc = libusb_get_device_descriptor(dev, &desc);
    +    if (rc)
    +        return rc;
    +    if (desc.bDeviceClass != LIBUSB_CLASS_HUB)
    +        return LIBUSB_ERROR_INVALID_PARAM;
    +    int bcd_usb = libusb_le16_to_cpu(desc.bcdUSB);
    +    int desc_type = bcd_usb >= USB_SS_BCD ? LIBUSB_DT_SUPERSPEED_HUB
    +                                          : LIBUSB_DT_HUB;
    +    rc = libusb_open(dev, &devh);
    +    if (rc == 0) {
    +        len = libusb_control_transfer(devh,
    +            LIBUSB_ENDPOINT_IN | LIBUSB_REQUEST_TYPE_CLASS
    +                               | LIBUSB_RECIPIENT_DEVICE, /* hub status */
    +            LIBUSB_REQUEST_GET_DESCRIPTOR,
    +            desc_type << 8,
    +            0,
    +            buf, sizeof(buf),
    +            USB_CTRL_GET_TIMEOUT
    +        );
    +
    +        if (len >= minlen) {
    +            info->dev     = dev;
    +            info->bcd_usb = bcd_usb;
    +            info->super_speed = (bcd_usb >= USB_SS_BCD);
    +            info->nports  = uhd->bNbrPorts;
    +            snprintf(
    +                info->vendor, sizeof(info->vendor),
    +                "%04x:%04x",
    +                libusb_le16_to_cpu(desc.idVendor),
    +                libusb_le16_to_cpu(desc.idProduct)
    +            );
    +
    +            /* Convert bus and ports array into USB location string */
    +            info->bus = libusb_get_bus_number(dev);
    +            snprintf(info->location, sizeof(info->location), "%d", info->bus);
    +            info->pn_len = get_port_numbers(dev, info->port_numbers, sizeof(info->port_numbers));
    +            int k;
    +            for (k=0; k < info->pn_len; k++) {
    +                char s[8];
    +                snprintf(s, sizeof(s), "%s%d", k==0 ? "-" : ".", info->port_numbers[k]);
    +                strcat(info->location, s);
    +            }
    +
    +            /* Get container_id: */
    +            bzero(info->container_id, sizeof(info->container_id));
    +            struct libusb_bos_descriptor *bos;
    +            rc = libusb_get_bos_descriptor(devh, &bos);
    +            if (rc == 0) {
    +                int cap;
    +#ifdef __FreeBSD__
    +                for (cap=0; cap < bos->bNumDeviceCapabilities; cap++) {
    +#else
    +                for (cap=0; cap < bos->bNumDeviceCaps; cap++) {
    +#endif
    +                    if (bos->dev_capability[cap]->bDevCapabilityType == LIBUSB_BT_CONTAINER_ID) {
    +                        struct libusb_container_id_descriptor *container_id;
    +                        rc = libusb_get_container_id_descriptor(NULL, bos->dev_capability[cap], &container_id);
    +                        if (rc == 0) {
    +                            int i;
    +                            for (i=0; i<16; i++) {
    +                                sprintf(info->container_id+i*2, "%02x", container_id->ContainerID[i]);
    +                            }
    +                            info->container_id[i*2] = 0;
    +                            libusb_free_container_id_descriptor(container_id);
    +                        }
    +                    }
    +                }
    +                libusb_free_bos_descriptor(bos);
    +
    +                /* Raspberry Pi 4B hack for USB3 root hub: */
    +                if (strlen(info->container_id)==0 &&
    +                    strcasecmp(info->vendor, "1d6b:0003")==0 &&
    +                    info->pn_len==0 &&
    +                    info->nports==4 &&
    +                    bcd_usb==USB_SS_BCD)
    +                {
    +                    strcpy(info->container_id, "5cf3ee30d5074925b001802d79434c30");
    +                }
    +            }
    +
    +            /* Logical Power Switching Mode */
    +            int lpsm = uhd->wHubCharacteristics[0] & HUB_CHAR_LPSM;
    +            if (lpsm == HUB_CHAR_COMMON_LPSM && info->nports == 1) {
    +                /* For 1 port hubs, ganged power switching is the same as per-port: */
    +                lpsm = HUB_CHAR_INDV_PORT_LPSM;
    +            }
    +            /* Raspberry Pi 4B reports inconsistent descriptors, override: */
    +            if (lpsm == HUB_CHAR_COMMON_LPSM && strcasecmp(info->vendor, "2109:3431")==0) {
    +                lpsm = HUB_CHAR_INDV_PORT_LPSM;
    +            }
    +            info->lpsm = lpsm;
    +            rc = 0;
    +        } else {
    +            rc = len;
    +        }
    +        libusb_close(devh);
    +    }
    +    return rc;
    +}
    +
    +
    +/*
    + * Assuming that devh is opened device handle for USB hub,
    + * return state for given hub port.
    + * In case of error, returns -1 (inspect errno for more information).
    + */
    +
    +static int get_port_status(struct libusb_device_handle *devh, int port)
    +{
    +    int rc;
    +    struct usb_port_status ust;
    +    if (devh == NULL)
    +        return -1;
    +
    +    rc = libusb_control_transfer(devh,
    +        LIBUSB_ENDPOINT_IN | LIBUSB_REQUEST_TYPE_CLASS
    +                           | LIBUSB_RECIPIENT_OTHER, /* port status */
    +        LIBUSB_REQUEST_GET_STATUS, 0,
    +        port, (unsigned char*)&ust, sizeof(ust),
    +        USB_CTRL_GET_TIMEOUT
    +    );
    +
    +    if (rc < 0) {
    +        return rc;
    +    }
    +    return ust.wPortStatus;
    +}
    +
    +
    +#ifdef __gnu_linux__
    +/*
    + * Try to use the Linux sysfs interface to power a port off/on.
    + * Returns 0 on success.
    + */
    +
    +static int set_port_status_linux(struct libusb_device_handle *devh, struct hub_info *hub, int port, int on)
    +{
    +    int configuration = 0;
    +    char disable_path[PATH_MAX];
    +
    +    int rc = libusb_get_configuration(devh, &configuration);
    +    if (rc < 0) {
    +        return rc;
    +    }
    +
    +    /*
    +     * The "disable" sysfs interface is available only starting with kernel version 6.0.
    +     * For earlier kernel versions the open() call will fail and we fall back to using libusb.
    +     */
    +    snprintf(disable_path, PATH_MAX,
    +        "/sys/bus/usb/devices/%s:%d.0/%s-port%i/disable",
    +        hub->location, configuration, hub->location, port
    +    );
    +
    +    int disable_fd = open(disable_path, O_WRONLY);
    +    if (disable_fd >= 0) {
    +        rc = write(disable_fd, on ? "0" : "1", 1);
    +        close(disable_fd);
    +    }
    +
    +    if (disable_fd < 0 || rc < 0) {
    +        /*
    +         * ENOENT is the expected error when running on Linux kernel < 6.0 where
    +         * sysfs disable interface does not exist yet - no need to report anything in this case.
    +         * If the file exists but another error occurs it is most likely a permission issue.
    +         * Print an error message mostly geared towards setting up udev.
    +         */
    +        if (errno != ENOENT) {
    +            fprintf(stderr,
    +                "Failed to set port status by writing to %s (%s).\n"
    +                "Follow https://git.io/JIB2Z to make sure that udev is set up correctly.\n"
    +                "Falling back to libusb based port control.\n"
    +                "Use -S to skip trying the sysfs interface and printing this message.\n",
    +                disable_path, strerror(errno)
    +            );
    +        }
    +
    +        return -1;
    +    }
    +
    +    return 0;
    +}
    +#endif
    +
    +
    +/*
    + * Use a control transfer via libusb to turn a port off/on.
    + * Returns >= 0 on success.
    + */
    +
    +static int set_port_status_libusb(struct libusb_device_handle *devh, int port, int on)
    +{
    +    int rc = 0;
    +    int request = on ? LIBUSB_REQUEST_SET_FEATURE
    +                     : LIBUSB_REQUEST_CLEAR_FEATURE;
    +    int repeat = on ? 1 : opt_repeat;
    +
    +    while (repeat-- > 0) {
    +        rc = libusb_control_transfer(devh,
    +            LIBUSB_REQUEST_TYPE_CLASS | LIBUSB_RECIPIENT_OTHER,
    +            request, USB_PORT_FEAT_POWER,
    +            port, NULL, 0, USB_CTRL_GET_TIMEOUT
    +        );
    +        if (rc < 0) {
    +            perror("Failed to control port power!\n");
    +        }
    +        if (repeat > 0) {
    +            sleep_ms(opt_wait);
    +        }
    +    }
    +
    +    return rc;
    +}
    +
    +
    +/*
    + * Try different methods to power a port off/on.
    + * Return >= 0 on success.
    + */
    +
    +static int set_port_status(struct libusb_device_handle *devh, struct hub_info *hub, int port, int on)
    +{
    +#ifdef __gnu_linux__
    +    if (!opt_nosysfs) {
    +        if (set_port_status_linux(devh, hub, port, on) == 0) {
    +            return 0;
    +        }
    +    }
    +#else
    +    (void)hub;
    +#endif
    +
    +    return set_port_status_libusb(devh, port, on);
    +}
    +
    +
    +/*
    + * Get USB device descriptor strings and summary description.
    + *
    + * Summary will use following format:
    + *
    + *    "<vid:pid> <vendor> <product> <serial>, <USB x.yz, N ports>"
    + *
    + * vid:pid will be always present, but vendor, product or serial
    + * may be skipped if they are empty or not enough permissions to read them.
    + * <USB x.yz, N ports> will be present only for USB hubs.
    + *
    + * Returns 0 for success and error code for failure.
    + * In case of failure return buffer is not altered.
    + */
    +
    +static int get_device_description(struct libusb_device * dev, struct descriptor_strings * ds)
    +{
    +    int rc;
    +    int id_vendor  = 0;
    +    int id_product = 0;
    +    char hub_specific[64] = "";
    +    struct libusb_device_descriptor desc;
    +    struct libusb_device_handle *devh = NULL;
    +    rc = libusb_get_device_descriptor(dev, &desc);
    +    if (rc)
    +        return rc;
    +    bzero(ds, sizeof(*ds));
    +    id_vendor  = libusb_le16_to_cpu(desc.idVendor);
    +    id_product = libusb_le16_to_cpu(desc.idProduct);
    +    rc = libusb_open(dev, &devh);
    +    if (rc == 0) {
    +        if (!opt_nodesc) {
    +            if (desc.iManufacturer) {
    +                rc = libusb_get_string_descriptor_ascii(devh,
    +                    desc.iManufacturer, (unsigned char*)ds->vendor, sizeof(ds->vendor));
    +                rtrim(ds->vendor);
    +            }
    +            if (rc >= 0 && desc.iProduct) {
    +                rc = libusb_get_string_descriptor_ascii(devh,
    +                    desc.iProduct, (unsigned char*)ds->product, sizeof(ds->product));
    +                rtrim(ds->product);
    +            }
    +            if (rc >= 0 && desc.iSerialNumber) {
    +                rc = libusb_get_string_descriptor_ascii(devh,
    +                    desc.iSerialNumber, (unsigned char*)ds->serial, sizeof(ds->serial));
    +                rtrim(ds->serial);
    +            }
    +        }
    +        if (desc.bDeviceClass == LIBUSB_CLASS_HUB) {
    +            struct hub_info info;
    +            rc = get_hub_info(dev, &info);
    +            if (rc == 0) {
    +                const char * lpsm_type;
    +                if (info.lpsm == HUB_CHAR_INDV_PORT_LPSM) {
    +                    lpsm_type = "ppps";
    +                } else if (info.lpsm == HUB_CHAR_COMMON_LPSM) {
    +                    lpsm_type = "ganged";
    +                } else {
    +                    lpsm_type = "nops";
    +                }
    +                snprintf(hub_specific, sizeof(hub_specific), ", USB %x.%02x, %d ports, %s",
    +                   info.bcd_usb >> 8, info.bcd_usb & 0xFF, info.nports, lpsm_type);
    +            }
    +        }
    +        libusb_close(devh);
    +    }
    +    snprintf(ds->description, sizeof(ds->description),
    +        "%04x:%04x%s%s%s%s%s%s%s",
    +        id_vendor, id_product,
    +        ds->vendor[0]  ? " " : "", ds->vendor,
    +        ds->product[0] ? " " : "", ds->product,
    +        ds->serial[0]  ? " " : "", ds->serial,
    +        hub_specific
    +    );
    +    return 0;
    +}
    +
    +
    +/*
    + * show status for hub ports
    + * portmask is bitmap of ports to display
    + * if portmask is 0, show all ports
    + */
    +
    +static int print_port_status(struct hub_info * hub, int portmask)
    +{
    +    int port_status;
    +    struct libusb_device_handle * devh = NULL;
    +    int rc = 0;
    +    struct libusb_device *dev = hub->dev;
    +    rc = libusb_open(dev, &devh);
    +    if (rc == 0) {
    +        int port;
    +        for (port = 1; port <= hub->nports; port++) {
    +            if (portmask > 0 && (portmask & (1 << (port-1))) == 0) continue;
    +
    +            port_status = get_port_status(devh, port);
    +            if (port_status == -1) {
    +                fprintf(stderr,
    +                    "cannot read port %d status, %s (%d)\n",
    +                    port, strerror(errno), errno);
    +                break;
    +            }
    +
    +            printf("  Port %d: %04x", port, port_status);
    +
    +            struct descriptor_strings ds;
    +            bzero(&ds, sizeof(ds));
    +            struct libusb_device * udev;
    +            int i = 0;
    +            while ((udev = usb_devs[i++]) != NULL) {
    +                uint8_t dev_bus;
    +                uint8_t dev_pn[MAX_HUB_CHAIN];
    +                int dev_plen;
    +                dev_bus = libusb_get_bus_number(udev);
    +                /* only match devices on the same bus: */
    +                if (dev_bus != hub->bus) continue;
    +                dev_plen = get_port_numbers(udev, dev_pn, sizeof(dev_pn));
    +                if ((dev_plen == hub->pn_len + 1) &&
    +                    (memcmp(hub->port_numbers, dev_pn, hub->pn_len) == 0) &&
    +                    libusb_get_port_number(udev) == port)
    +                {
    +                    rc = get_device_description(udev, &ds);
    +                    if (rc == 0)
    +                        break;
    +                }
    +            }
    +
    +            if (!hub->super_speed) {
    +                if (port_status == 0) {
    +                    printf(" off");
    +                } else {
    +                    if (port_status & USB_PORT_STAT_POWER)        printf(" power");
    +                    if (port_status & USB_PORT_STAT_INDICATOR)    printf(" indicator");
    +                    if (port_status & USB_PORT_STAT_TEST)         printf(" test");
    +                    if (port_status & USB_PORT_STAT_HIGH_SPEED)   printf(" highspeed");
    +                    if (port_status & USB_PORT_STAT_LOW_SPEED)    printf(" lowspeed");
    +                    if (port_status & USB_PORT_STAT_SUSPEND)      printf(" suspend");
    +                }
    +            } else {
    +                if (!(port_status & USB_SS_PORT_STAT_POWER)) {
    +                    printf(" off");
    +                } else {
    +                    int link_state = port_status & USB_PORT_STAT_LINK_STATE;
    +                    if (port_status & USB_SS_PORT_STAT_POWER)     printf(" power");
    +                    if ((port_status & USB_SS_PORT_STAT_SPEED)
    +                         == USB_PORT_STAT_SPEED_5GBPS)
    +                    {
    +                        printf(" 5gbps");
    +                    }
    +                    if (link_state == USB_SS_PORT_LS_U0)          printf(" U0");
    +                    if (link_state == USB_SS_PORT_LS_U1)          printf(" U1");
    +                    if (link_state == USB_SS_PORT_LS_U2)          printf(" U2");
    +                    if (link_state == USB_SS_PORT_LS_U3)          printf(" U3");
    +                    if (link_state == USB_SS_PORT_LS_SS_DISABLED) printf(" SS.Disabled");
    +                    if (link_state == USB_SS_PORT_LS_RX_DETECT)   printf(" Rx.Detect");
    +                    if (link_state == USB_SS_PORT_LS_SS_INACTIVE) printf(" SS.Inactive");
    +                    if (link_state == USB_SS_PORT_LS_POLLING)     printf(" Polling");
    +                    if (link_state == USB_SS_PORT_LS_RECOVERY)    printf(" Recovery");
    +                    if (link_state == USB_SS_PORT_LS_HOT_RESET)   printf(" HotReset");
    +                    if (link_state == USB_SS_PORT_LS_COMP_MOD)    printf(" Compliance");
    +                    if (link_state == USB_SS_PORT_LS_LOOPBACK)    printf(" Loopback");
    +                }
    +            }
    +            if (port_status & USB_PORT_STAT_RESET)       printf(" reset");
    +            if (port_status & USB_PORT_STAT_OVERCURRENT) printf(" oc");
    +            if (port_status & USB_PORT_STAT_ENABLE)      printf(" enable");
    +            if (port_status & USB_PORT_STAT_CONNECTION)  printf(" connect");
    +
    +            if (port_status & USB_PORT_STAT_CONNECTION)  printf(" [%s]", ds.description);
    +
    +            printf("\n");
    +        }
    +        libusb_close(devh);
    +    }
    +    return 0;
    +}
    +
    +
    +/*
    + *  Find all USB hubs and fill hubs[] array.
    + *  Set actionable to 1 on all hubs that we are going to operate on
    + *  (this applies possible constraints like location or vendor).
    + *  Returns count of found actionable physical hubs
    + *  (USB3 hubs are counted once despite having USB2 dual partner).
    + *  In case of error returns negative error code.
    + */
    +
    +static int usb_find_hubs(void)
    +{
    +    struct libusb_device *dev;
    +    int perm_ok = 1;
    +    int rc = 0;
    +    int i = 0;
    +    int j = 0;
    +    while ((dev = usb_devs[i++]) != NULL) {
    +        struct libusb_device_descriptor desc;
    +        rc = libusb_get_device_descriptor(dev, &desc);
    +        /* only scan for hubs: */
    +        if (rc == 0 && desc.bDeviceClass != LIBUSB_CLASS_HUB)
    +            continue;
    +        struct hub_info info;
    +        bzero(&info, sizeof(info));
    +        rc = get_hub_info(dev, &info);
    +        if (rc) {
    +            perm_ok = 0; /* USB permission issue? */
    +            continue;
    +        }
    +        get_device_description(dev, &info.ds);
    +        if (info.lpsm != HUB_CHAR_INDV_PORT_LPSM && !opt_force) {
    +            continue;
    +        }
    +        info.actionable = 1;
    +        if (strlen(opt_search) > 0) {
    +            /* Search by attached device description */
    +            info.actionable = 0;
    +            struct libusb_device * udev;
    +            int k = 0;
    +            while ((udev = usb_devs[k++]) != NULL) {
    +                uint8_t dev_pn[MAX_HUB_CHAIN];
    +                uint8_t dev_bus = libusb_get_bus_number(udev);
    +                /* only match devices on the same bus: */
    +                if (dev_bus != info.bus) continue;
    +                int dev_plen = get_port_numbers(udev, dev_pn, sizeof(dev_pn));
    +                if ((dev_plen == info.pn_len + 1) &&
    +                    (memcmp(info.port_numbers, dev_pn, info.pn_len) == 0))
    +                {
    +                    struct descriptor_strings ds;
    +                    bzero(&ds, sizeof(ds));
    +                    rc = get_device_description(udev, &ds);
    +                    if (rc != 0)
    +                        break;
    +                    if (strstr(ds.description, opt_search)) {
    +                        info.actionable = 1;
    +                        opt_ports &= 1 << (dev_pn[dev_plen-1] - 1);
    +                        break;
    +                    }
    +                }
    +            }
    +        }
    +        if (strlen(opt_location) > 0) {
    +            if (strcasecmp(opt_location, info.location)) {
    +                info.actionable = 0;
    +            }
    +        }
    +        if (opt_level > 0) {
    +            if (opt_level != info.pn_len + 1) {
    +                info.actionable = 0;
    +            }
    +        }
    +        if (strlen(opt_vendor) > 0) {
    +            if (strncasecmp(opt_vendor, info.vendor, strlen(opt_vendor))) {
    +                info.actionable = 0;
    +            }
    +        }
    +        memcpy(&hubs[hub_count], &info, sizeof(info));
    +        if (hub_count < MAX_HUBS) {
    +            hub_count++;
    +        } else {
    +            /* That should be impossible - but we don't want to crash! */
    +            fprintf(stderr, "Too many hubs!");
    +            break;
    +        }
    +    }
    +    if (!opt_exact) {
    +        /* Handle USB2/3 duality: */
    +        for (i=0; i<hub_count; i++) {
    +            /* Check only actionable hubs: */
    +            if (hubs[i].actionable != 1)
    +                continue;
    +            /* Must have non empty container ID: */
    +            if (strlen(hubs[i].container_id) == 0)
    +                continue;
    +            int best_match = -1;
    +            int best_score = -1;
    +            for (j=0; j<hub_count; j++) {
    +                if (i==j)
    +                    continue;
    +
    +                /* Find hub which is USB2/3 dual to the hub above */
    +
    +                /* Hub and its dual must be different types: one USB2, another USB3: */
    +                if (hubs[i].super_speed == hubs[j].super_speed)
    +                    continue;
    +
    +                /* Must have non empty container ID: */
    +                if (strlen(hubs[j].container_id) == 0)
    +                    continue;
    +
    +                /* Per USB 3.0 spec chapter 11.2, container IDs must match: */
    +                if (strcmp(hubs[i].container_id, hubs[j].container_id) != 0)
    +                    continue;
    +
    +                /* At this point, it should be enough to claim a match.
    +                 * However, some devices use hardcoded non-unique container ID.
    +                 * We should do few more checks below if multiple such devices are present.
    +                 */
    +
    +                /* Hubs should have the same number of ports */
    +                if (hubs[i].nports != hubs[j].nports) {
    +                    /* Except for some weird hubs like Apple mini-dock (has 2 usb2 + 1 usb3 ports) */
    +                    if (hubs[i].nports + hubs[j].nports > 3) {
    +                        continue;
    +                    }
    +                }
    +
    +                /* If serial numbers are both present, they must match: */
    +                if ((strlen(hubs[i].ds.serial) > 0 && strlen(hubs[j].ds.serial) > 0) &&
    +                    strcmp(hubs[i].ds.serial, hubs[j].ds.serial) != 0)
    +                {
    +                    continue;
    +                }
    +
    +                /* We have first possible candidate, but need to keep looking for better one */
    +
    +                if (best_score < 1) {
    +                    best_score = 1;
    +                    best_match = j;
    +                }
    +
    +                /* Checks for various levels of USB2 vs USB3 path similarity... */
    +
    +                uint8_t* p1 = hubs[i].port_numbers;
    +                uint8_t* p2 = hubs[j].port_numbers;
    +                int l1 = hubs[i].pn_len;
    +                int l2 = hubs[j].pn_len;
    +                int s1 = hubs[i].super_speed;
    +                int s2 = hubs[j].super_speed;
    +
    +                /* Check if port path is the same after removing top level (needed for M1 Macs): */
    +                if (l1 >= 1 && l1 == l2 && memcmp(p1 + 1, p2 + 1, l1 - 1)==0) {
    +                    if (best_score < 2) {
    +                        best_score = 2;
    +                        best_match = j;
    +                    }
    +                }
    +
    +                /* Raspberry Pi 4B hack (USB2 hub is one level deeper than USB3): */
    +                if (l1 + s1 == l2 + s2 && l1 >= s2 && memcmp(p1 + s2, p2 + s1, l1 - s2)==0) {
    +                    if (best_score < 3) {
    +                        best_score = 3;
    +                        best_match = j;
    +                    }
    +                }
    +                /* Check if port path is exactly the same: */
    +                if (l1 == l2 && memcmp(p1, p2, l1)==0) {
    +                    if (best_score < 4) {
    +                        best_score = 4;
    +                        best_match = j;
    +                    }
    +                    /* Give even higher priority if `usb2bus + 1 == usb3bus` (Linux specific): */
    +                    if (hubs[i].bus - s1 == hubs[j].bus - s2) {
    +                        if (best_score < 5) {
    +                            best_score = 5;
    +                            best_match = j;
    +                        }
    +                    }
    +                }
    +            }
    +            if (best_match >= 0) {
    +                if (!hubs[best_match].actionable) {
    +                    /* Use 2 to signify that this is derived dual device */
    +                    hubs[best_match].actionable = 2;
    +                }
    +            }
    +        }
    +    }
    +    hub_phys_count = 0;
    +    for (i=0; i<hub_count; i++) {
    +        if (!hubs[i].actionable)
    +            continue;
    +        if (!hubs[i].super_speed || opt_exact) {
    +            hub_phys_count++;
    +        }
    +    }
    +    if (perm_ok == 0 && hub_phys_count == 0) {
    +#ifdef __gnu_linux__
    +        if (geteuid() != 0) {
    +            fprintf(stderr,
    +                "There were permission problems while accessing USB.\n"
    +                "Follow https://git.io/JIB2Z for a fix!\n"
    +            );
    +        }
    +#endif
    +        return LIBUSB_ERROR_ACCESS;
    +    }
    +    return hub_phys_count;
    +}
    +
    +
    +int main(int argc, char *argv[])
    +{
    +    int rc;
    +    int c = 0;
    +    int option_index = 0;
    +
    +    for (;;) {
    +        c = getopt_long(argc, argv, short_options, long_options, &option_index);
    +        if (c == -1)
    +            break;  /* no more options left */
    +        switch (c) {
    +        case 0:
    +            /* If this option set a flag, do nothing else now. */
    +            if (long_options[option_index].flag != 0)
    +                break;
    +            printf("option %s", long_options[option_index].name);
    +            if (optarg)
    +                printf(" with arg %s", optarg);
    +            printf("\n");
    +            break;
    +        case 'l':
    +            snprintf(opt_location, sizeof(opt_location), "%s", optarg);
    +            break;
    +        case 'L':
    +            opt_level = atoi(optarg);
    +            break;
    +        case 'n':
    +            snprintf(opt_vendor, sizeof(opt_vendor), "%s", optarg);
    +            break;
    +        case 's':
    +            snprintf(opt_search, sizeof(opt_search), "%s", optarg);
    +            break;
    +        case 'p':
    +            if (!strcasecmp(optarg, "all")) { /* all ports is the default */
    +                break;
    +            }
    +            if (strlen(optarg)) {
    +                opt_ports = ports2bitmap(optarg);
    +            }
    +            break;
    +        case 'a':
    +            if (!strcasecmp(optarg, "off")   || !strcasecmp(optarg, "0")) {
    +                opt_action = POWER_OFF;
    +            }
    +            if (!strcasecmp(optarg, "on")    || !strcasecmp(optarg, "1")) {
    +                opt_action = POWER_ON;
    +            }
    +            if (!strcasecmp(optarg, "cycle") || !strcasecmp(optarg, "2")) {
    +                opt_action = POWER_CYCLE;
    +            }
    +            if (!strcasecmp(optarg, "toggle") || !strcasecmp(optarg, "3")) {
    +                opt_action = POWER_TOGGLE;
    +            }
    +            break;
    +        case 'd':
    +            opt_delay = atof(optarg);
    +            break;
    +        case 'r':
    +            opt_repeat = atoi(optarg);
    +            break;
    +        case 'f':
    +            opt_force = 1;
    +            break;
    +        case 'N':
    +            opt_nodesc = 1;
    +            break;
    +#ifdef __gnu_linux__
    +        case 'S':
    +            opt_nosysfs = 1;
    +            break;
    +#endif
    +        case 'e':
    +            opt_exact = 1;
    +            break;
    +        case 'R':
    +            opt_reset = 1;
    +            break;
    +        case 'w':
    +            opt_wait = atoi(optarg);
    +            break;
    +        case 'v':
    +            printf("%s\n", PROGRAM_VERSION);
    +            exit(0);
    +            break;
    +        case 'h':
    +            print_usage();
    +            exit(1);
    +            break;
    +        case '?':
    +            /* getopt_long has already printed an error message here */
    +            fprintf(stderr, "Run with -h to get usage info.\n");
    +            exit(1);
    +            break;
    +        default:
    +            abort();
    +        }
    +    }
    +    if (optind < argc) {
    +        /* non-option parameters are found? */
    +        fprintf(stderr, "Invalid command line syntax!\n");
    +        fprintf(stderr, "Run with -h to get usage info.\n");
    +        exit(1);
    +    }
    +
    +    rc = libusb_init(NULL);
    +    if (rc < 0) {
    +        fprintf(stderr,
    +            "Error initializing USB!\n"
    +        );
    +        exit(1);
    +    }
    +
    +    rc = libusb_get_device_list(NULL, &usb_devs);
    +    if (rc < 0) {
    +        fprintf(stderr,
    +            "Cannot enumerate USB devices!\n"
    +        );
    +        rc = 1;
    +        goto cleanup;
    +    }
    +
    +    rc = usb_find_hubs();
    +    if (rc <= 0) {
    +        fprintf(stderr,
    +            "No compatible devices detected%s%s!\n"
    +            "Run with -h to get usage info.\n",
    +            strlen(opt_location) ? " at location " : "",
    +            opt_location
    +        );
    +        rc = 1;
    +        goto cleanup;
    +    }
    +
    +    if (hub_phys_count > 1 && opt_action >= 0) {
    +        fprintf(stderr,
    +            "Error: changing port state for multiple hubs at once is not supported.\n"
    +            "Use -l to limit operation to one hub!\n"
    +        );
    +        exit(1);
    +    }
    +    int k; /* k=0 for power OFF, k=1 for power ON */
    +    for (k=0; k<2; k++) { /* up to 2 power actions - off/on */
    +        if (k == 0 && opt_action == POWER_ON )
    +            continue;
    +        if (k == 1 && opt_action == POWER_OFF)
    +            continue;
    +        if (k == 1 && opt_action == POWER_KEEP)
    +            continue;
    +        /* if toggle requested, do it only once when `k == 0` */
    +        if (k == 1 && opt_action == POWER_TOGGLE)
    +            continue;
    +        int i;
    +        for (i=0; i<hub_count; i++) {
    +            if (hubs[i].actionable == 0)
    +                continue;
    +            printf("Current status for hub %s [%s]\n",
    +                hubs[i].location, hubs[i].ds.description
    +            );
    +            print_port_status(&hubs[i], opt_ports);
    +            if (opt_action == POWER_KEEP) { /* no action, show status */
    +                continue;
    +            }
    +            struct libusb_device_handle * devh = NULL;
    +            rc = libusb_open(hubs[i].dev, &devh);
    +            if (rc == 0) {
    +                /* will operate on these ports */
    +                int ports = ((1 << hubs[i].nports) - 1) & opt_ports;
    +                int should_be_on = k;
    +
    +                int port;
    +                for (port=1; port <= hubs[i].nports; port++) {
    +                    if ((1 << (port-1)) & ports) {
    +                        int port_status = get_port_status(devh, port);
    +                        int power_mask = hubs[i].super_speed ? USB_SS_PORT_STAT_POWER
    +                                                             : USB_PORT_STAT_POWER;
    +                        int is_on = (port_status & power_mask) != 0;
    +
    +                        if (opt_action == POWER_TOGGLE) {
    +                            should_be_on = !is_on;
    +                        }
    +
    +                        if (is_on != should_be_on) {
    +                            rc = set_port_status(devh, &hubs[i], port, should_be_on);
    +                        }
    +                    }
    +                }
    +                /* USB3 hubs need extra delay to actually turn off: */
    +                if (k==0 && hubs[i].super_speed)
    +                    sleep_ms(150);
    +                printf("Sent power %s request\n", should_be_on ? "on" : "off");
    +                printf("New status for hub %s [%s]\n",
    +                    hubs[i].location, hubs[i].ds.description
    +                );
    +                print_port_status(&hubs[i], opt_ports);
    +
    +                if (k == 1 && opt_reset == 1) {
    +                    printf("Resetting hub...\n");
    +                    rc = libusb_reset_device(devh);
    +                    if (rc < 0) {
    +                        perror("Reset failed!\n");
    +                    } else {
    +                        printf("Reset successful!\n");
    +                    }
    +                }
    +            }
    +            libusb_close(devh);
    +        }
    +        if (k == 0 && opt_action == POWER_CYCLE)
    +            sleep_ms((int)(opt_delay * 1000));
    +    }
    +    rc = 0;
    +cleanup:
    +    if (usb_devs)
    +        libusb_free_device_list(usb_devs, 1);
    +    usb_devs = NULL;
    +    libusb_exit(NULL);
    +    return rc;
    +}
    diff --git a/uninstall.sh b/uninstall.sh
    index a7aa54e3e..989d59d35 100755
    --- a/uninstall.sh
    +++ b/uninstall.sh
    @@ -1,13 +1,14 @@
     #!/bin/bash
     
    -[[ -z "${ALLSKY_HOME}" ]] && export ALLSKY_HOME="$(realpath "$(dirname "${BASH_ARGV0}")")"
    -# ME="$(basename "${BASH_ARGV0}")"
    +[[ -z "${ALLSKY_HOME}" ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )" )"
    +ME="$( basename "${BASH_ARGV0}" )"
     
    -#shellcheck disable=SC2086 source-path=.
    -source "${ALLSKY_HOME}/variables.sh"			|| exit ${ALLSKY_ERROR_STOP}
    +#shellcheck source-path=.
    +source "${ALLSKY_HOME}/variables.sh"			|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/functions.sh"			|| exit "${EXIT_ERROR_STOP}"
     
    -#shellcheck disable=SC2086
    -cd "${ALLSKY_HOME}"  									|| exit ${ALLSKY_ERROR_STOP}
    +cd "${ALLSKY_HOME}"  							|| exit "${EXIT_ERROR_STOP}"
     
     MSG="This will remove all non-config, system files from your computer.\n"
     MSG="${MSG}Note: This only removes files in their default location.\n"
    @@ -20,8 +21,10 @@ if whiptail --title "${TITLE}" --yesno "${MSG}" 10 60 3>&1 1>&2 2>&3; then
     	echo
         echo -e "A few things of note:"
         echo -e "  - To remove ALL traces of 'allsky' (${RED}This cannot be undone!${NC}), run:"
    -# TODO: remove everything else, e.g., website if installed, lighttpd, ...
    -	echo -e "     ${YELLOW}cd; sudo rm -rf allsky${NC}"
    +# TODO: remove everything else, e.g., lighttpd, /var/log/allsky*, ...
    +	echo -e "     ${YELLOW}cd${NC}"
    +	echo -e "     ${YELLOW}sudo umount tmp${NC}"
    +	echo -e "     ${YELLOW}sudo rm -rf allsky${NC}"
     	echo
         echo -e "  - If you wish to only remove config files, run:"
     	echo -e "     ${YELLOW}sudo make remove_configs${NC}"
    diff --git a/upgrade.sh b/upgrade.sh
    index 0c507ad94..3b4b2c10e 100755
    --- a/upgrade.sh
    +++ b/upgrade.sh
    @@ -1,62 +1,62 @@
     #!/bin/bash
     
    -# Upgrade an existing release of Allsky.
    -# This includes upgrading code as well as configuration files.
    -
    -############################
    -# TODO: This file currently just checks if there's a newer version on GitHub,
    -# and if so, it grabs the newer version and executes it.
    -# We did this so we could distribute a basic script with the new release,
    -# but didn't have time to fully complete and test this script.
    -############################
    -# TODO: Move variables and functions used by this script and install.sh into
    -# scripts/installUpgradeFunctions.sh, including functions in functiton.sh that
    -# are only used by upgrade.sh and install.sh.
    -############################
    -
    -
    -[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$(realpath "$(dirname "${BASH_ARGV0}")")"
    -ME="$(basename "${BASH_ARGV0}")"
    -
    -#shellcheck disable=SC2086 source-path=.
    -source "${ALLSKY_HOME}/variables.sh"		|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086 source-path=scripts
    -source "${ALLSKY_SCRIPTS}/functions.sh"		|| exit ${ALLSKY_ERROR_STOP}
    -
    -if [[ ${EUID} -eq 0 ]]; then
    -	display_msg error "This script must NOT be run as root, do NOT use 'sudo'."
    -	exit 1
    -fi
    -
    -#shellcheck disable=SC2086
    -cd "${ALLSKY_HOME}"  									|| exit ${ALLSKY_ERROR_STOP}
    -
    -
    -####
    -usage_and_exit()
    +# Upgrade the current Allsky release.
    +
    +[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$( realpath "$( dirname "${BASH_ARGV0}" )" )"
    +ME="$( basename "${BASH_ARGV0}" )"
    +
    +#shellcheck source-path=.
    +source "${ALLSKY_HOME}/variables.sh"					|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/functions.sh"					|| exit "${EXIT_ERROR_STOP}"
    +#shellcheck source-path=scripts
    +source "${ALLSKY_SCRIPTS}/installUpgradeFunctions.sh"	|| exit "${EXIT_ERROR_STOP}"
    +
    +# High-level view of tasks for upgrade:
    +#	Check if ${PRIOR_ALLSKY_DIR} exists.
    +#		If so, warn user they won't be able to save current release.
    +#	Prompt if user wants to carry current settings to new release.
    +#		If so:
    +#			If ${PRIOR_ALLSKY_DIR} exists, error out
    +#		Rename ${ALLSKY_HOME} to ${PRIOR_ALLSKY_DIR}
    +#	Download new release (with optional branch) from GitHub.
    +#	Execute new release's installation script telling it it's an upgrade.
    +
    +#############
    +# Changes to install.sh needed:
    +#	* Accept "--upgrade" argument which means we're doing an upgrade.
    +#		- Don't display "**** Welcome to the installer ****"
    +#		- Don't prompt for camera
    +#		- Don't prompt to reboot
    +#		- Don't prompt other things ??
    +#
    +#############
    +# TODO:
    +#	Check for symbolic links
    +#	Allow installing other branches.
    +#############
    +
    +############################## functions
    +function usage_and_exit()
     {
    -	RET=${1}
    -	if [[ ${RET} -eq 0 ]]; then
    -		C="${YELLOW}"
    -	else
    -		C="${RED}"
    -	fi
    -	# Don't show the "--newer", "--no-check", or "--force-check" options since users
    -	# should never use them.
    -	# TODO: Also don't show future --doUpgrade and --doUpgradeInPlace options.
    -	echo
    -	echo -e "${C}Usage: ${ME} [--help] [--debug] [--restore] [--function function]${NC}"
    -	echo
    -	echo "'--help' displays this message and exits."
    -	echo
    -	echo "'--debug' displays debugging information."
    -	echo
    -	echo "'--restore' restores a previously upgraded Allsky.  Rarely needed."
    -	echo
    -	echo "'--function' executes the specified function and quits."
    -	echo
    -	#shellcheck disable=SC2086
    -	exit ${RET}
    +	local RET=${1}
    +	{
    +		[[ ${RET} -ne 0 ]] && echo -e "${RED}"
    +		echo -e "\nUpgrade the Allsky software to a newer version.."
    +		echo
    +		echo -e "Usage: ${ME} [--help] [--debug] [--branch branch] [--function function]${NC}"
    +		echo
    +		echo "'--help' displays this message and exits."
    +		echo
    +		echo "'--debug' displays debugging information."
    +		echo
    +		echo "'--branch branch' uses 'branch' instead of the production branch."
    +		echo
    +		echo "'--function' executes the specified function and quits."
    +		echo
    +		[[ ${RET} -ne 0 ]] && echo -e "${NC}"
    +	} >&2
    +	exit "${RET}"
     }
     
     ####################### main part of program
    @@ -66,16 +66,12 @@ ALL_ARGS="$@"
     ##### Check arguments
     OK="true"
     HELP="false"
    -DEBUG="false"
    -DEBUG_ARG=""
    -NEWER="false"
    -ACTION="upgrade"
    -WORD="Upgrade"
    +DEBUG="false"; DEBUG_ARG=""
    +ACTION="upgrade"; WORD="Upgrade"		# default
     FUNCTION=""
    -FORCE_CHECK="true"		# Set to "true" to ALWAYS do the version check
    -while [ $# -gt 0 ]; do
    +while [[ $# -gt 0 ]]; do
     	ARG="${1}"
    -	case "${ARG}" in
    +	case "${ARG,,}" in
     		--help)
     			HELP="true"
     			;;
    @@ -83,23 +79,10 @@ while [ $# -gt 0 ]; do
     			DEBUG="true"
     			DEBUG_ARG="${ARG}"		# we can pass this to other scripts
     			;;
    -		--newer)
    -			NEWER="true"
    -			;;
    -		--restore)
    -			ACTION="restore"
    -			WORD="Restorer"
    -			;;
     		--function)
     			FUNCTION="${2}"
     			shift
     			;;
    -		--no-check)
    -			FORCE_CHECK="false"
    -			;;
    -		--force-check)
    -			FORCE_CHECK="true"
    -			;;
     		*)
     			display_msg error "Unknown argument: '${ARG}'."
     			OK="false"
    @@ -109,48 +92,26 @@ while [ $# -gt 0 ]; do
     done
     [[ ${HELP} == "true" ]] && usage_and_exit 0
     [[ ${OK} == "false" || $# -ne 0 ]] && usage_and_exit 1
    -
     [[ ${DEBUG} == "true" ]] && echo "Running: ${ME} ${ALL_ARGS}"
     
    +# shellcheck disable=SC2119
     BRANCH="$( get_branch )"
     [[ -z ${BRANCH} ]] && BRANCH="${GITHUB_MAIN_BRANCH}"
     
    -# Unless forced to, only do the version check if we're on the main branch,
    -# not on development branches, because when we're updating this script we
    -# don't want to have the updates overwritten from an older version on GitHub.
    -if [[ ${FORCE_CHECK} == "true" || ${BRANCH} == "${GITHUB_MAIN_BRANCH}" ]]; then
    -	CURRENT_SCRIPT="${ALLSKY_HOME}/${ME}"
    -	if [[ ${NEWER} == "true" ]]; then
    -		# This is the newer version
    -		echo "[${CURRENT_SCRIPT}] was replaced by newer version from GitHub."
    -		cp "${BASH_ARGV0}" "${CURRENT_SCRIPT}"
    -		chmod 775 "${CURRENT_SCRIPT}"
    -
    -	else
    -		# See if there's a newer version of this script; if so, download it and execute it.
    -		BRANCH="$(getBranch)" || exit 2
    -		NEWER_SCRIPT="/tmp/${ME}"
    -		checkAndGetNewerFile --branch "${BRANCH}" "${CURRENT_SCRIPT}" "${ME}" "${NEWER_SCRIPT}"
    -		RET=$?
    -		[[ ${RET} -eq 2 ]] && exit 2
    -		if [[ ${RET} -eq 1 ]]; then
    -			exec "${NEWER_SCRIPT}" --newer "${ALL_ARGS}"
    -			# Does not return
    -		fi
    -	fi
    -fi
    -
    -# TODO: these are here to keep shellcheck quiet.
    +# TODO: these are here to keep shellcheck quiet while this script is incomplete.
     DEBUG="${DEBUG}"
     DEBUG_ARG="${DEBUG_ARG}"
     FUNCTION="${FUNCTION}"
    -WORD="${WORD}"
    -
    -#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/config.sh"			|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/ftp-settings.sh"	|| exit ${ALLSKY_ERROR_STOP}
     
    +if [[ "${ACTION}" != "doUpgrade" ]]; then
    +	echo
    +	echo "***********************************************"
    +	echo "*** Welcome to the Allsky Software ${WORD} ***"
    +	echo "***********************************************"
    +	echo
    +else	# we're continuing where we left off, so don't welcome again.
    +	echo -e "* ${GREEN}Continuing the ${WORD}...${NC}"
    +fi
     
     if [[ ${ACTION} == "upgrade" ]]; then
     	:
    @@ -159,27 +120,26 @@ if [[ ${ACTION} == "upgrade" ]]; then
     
     	# Make sure we can upgrade:
     	#	If config/ does NOT exist, the user hasn't installed Allsky.
    -	#		Tell the user.
    -	#		Invoke install.sh, or exit ?????
    +	#		Warn the user but let them continue (won't be able to restore from prior).
     
     	# Ask user if they want to upgrade in place (i.e., overwrite code),
    -	# or move current code to ${ALLSKY_HOME}-OLD.
    +	# or move current code to ${PRIOR_ALLSKY_DIR}.
     
     	# If move current code:
     	#	Check for prior Allsky versions:
    -	#		If ${ALLSKY_HOME}-OLD exist:
    -	#			If ${ALLSKY_HOME}-OLDEST exists
    +	#		If ${PRIOR_ALLSKY_DIR} exist:
    +	#			If ${PRIOR_ALLSKY_DIR}-OLDEST exists
     	#				Let user know both old versions exist
     	#				Exit
    -	#			Let the user know ${ALLSKY_HOME}-OLD exists as FYI:
    -	#				echo "Saving prior version in ${ALLSKY_HOME}-OLDEST"
    -	#			Move ${ALLSKY_HOME}-OLD to ${ALLSKY_HOME}-OLDEST
    +	#			Let the user know ${PRIOR_ALLSKY_DIR} exists as FYI:
    +	#				echo "Saving prior version in ${PRIOR_ALLSKY_DIR}-OLDEST"
    +	#			Move ${PRIOR_ALLSKY_DIR} to ${PRIOR_ALLSKY_DIR}-OLDEST
     	#	Stop allsky
    -	#	Move ${ALLSKY_HOME} to ${ALLSKY_HOME}-OLD
    +	#	Move ${ALLSKY_HOME} to ${PRIOR_ALLSKY_DIR}
     	#	cd
     	#	Git new code into ${ALLSKY_HOME}
     	#	cd ${ALLSKY_HOME}
    -	#	Run: ./install.sh $DEBUG_ARG .... --doUpgrade
    +	#	Run: ./install.sh ${DEBUG_ARG} .... --doUpgrade
     	#		--doUpgrade tells it to use prior version without asking and to
     	#		not display header, change messages to say "upgrade", not "install", etc.
     	#	?? anything else?
    @@ -195,31 +155,4 @@ if [[ ${ACTION} == "upgrade" ]]; then
     	#		How is --doUpgradeInPlace different from --doUpgrade ??
     	#	?? anything else?
     
    -elif [[ ${ACTION} == "restore" ]]; then
    -	:
    -
    -	# If running in $ALLSKY_HOME		# us 1st time through
    -	#	Make sure ${ALLSKY_HOME}-OLD exists
    -	#		If not, warn user and exit:
    -	#			"No prior version to restore from: ${ALLSKY_HOME}-OLD does not exist".
    -	#	cp ${ME} /tmp
    -	#	chmod 775 /tmp/${ME}
    -	#	exec /tmp/${ME} --restore ${ALL_ARGS} $ALLSKY_HOME
    -
    -	# Else		# running from /tmp - do the actual work
    -	#	Stop allsky
    -	#	mv $ALLSKY_HOME} ${ALLSKY_HOME}-new_tmp
    -	#	mv ${ALLSKY_HOME}-OLD $ALLSKY_HOME
    -	#	move images from ${ALLSKY_HOME}-new_tmp to $ALLSKY_HOME
    -	#	move darks from ${ALLSKY_HOME}-new_tmp to $ALLSKY_HOME
    -	#	copy scripts/endOfNight_additionalSteps.sh from ${ALLSKY_HOME}-new_tmp to $ALLSKY_HOME
    -
    -	# Prompt the user if they want to:
    -	#	restore their old "images" folder (if there's anything in it)
    -	#	restore their old "darks" folder (if there's anything in it)
    -	#	restore their old configuration settings
    -	#		(config.sh, ftp-settings.sh, scripts/endOfNight_additionalSteps.sh)
    -	#	upgrade their WebUI (if installed)
    -	#	upgrade their Website (if installed)
    -
     fi
    diff --git a/variables.sh b/variables.sh
    index 8d67806f6..10911885b 100644
    --- a/variables.sh
    +++ b/variables.sh
    @@ -10,29 +10,33 @@ if [[ -z "${ALLSKY_VARIABLE_SET}" ]]; then
     
     	ALLSKY_VARIABLE_SET="true"	# so we only do the following once
     
    -	ME2="$(basename "${BASH_SOURCE[0]}")"
    +	ME2="$( basename "${BASH_SOURCE[0]}" )"
     
     	# Set colors used by many scripts in output.
     	# If we're not on a tty output is likely being written to a file, so don't use colors.
     	# The "w" colors are for when output may go to a web page.
     	if tty --silent ; then
    -		ON_TTY=1
    +		ON_TTY="true"
    +		DIALOG_RED="\Z1";		DIALOG_NORMAL="\Zn"
    +		DIALOG_UNDERLINE="\Zu"
     		GREEN="\033[0;32m";		wOK="${GREEN}"
     		YELLOW="\033[0;33m";	wWARNING="${YELLOW}"
     		RED="\033[0;31m";		wERROR="${RED}"
     		# Can't use DEBUG since lots of scripts use that to enable debugging
     		cDEBUG="${YELLOW}";		wDEBUG="${YELLOW}"
    +		BOLD="\033[1m";			wBOLD="["; wNBOLD="]"
     		NC="\033[0m";			wNC="${NC}"
    -								wBOLD="["; wNBOLD="]"
     								wBR="\n"
     	else
    -		ON_TTY=0
    +		ON_TTY="false"
    +		DIALOG_RED="";			DIALOG_NORMAL=""		
    +		DIALOG_UNDERLINE=""
     		GREEN="";				wOK="<span style='color: green'>"
     		YELLOW="";				wWARNING="<span style='color: #FF9800'>"
     		RED="";					wERROR="<span style='color: red'>"
     		cDEBUG="";				wDEBUG="${wWARNING}"
    +		BOLD="";				wBOLD="<b>"; wNBOLD="</b>"
     		NC="";					wNC="</span>"
    -								wBOLD="<b>"; wNBOLD="</b>"
     								wBR="<br>"
     	fi
     
    @@ -46,20 +50,15 @@ if [[ -z "${ALLSKY_VARIABLE_SET}" ]]; then
     	# Directory Allsky is installed in.
     	ALLSKY_INSTALL_DIR="$( basename "${ALLSKY_HOME}" )"
     
    -	# Optional prior copy of Allsky.
    -	PRIOR_ALLSKY_DIR="$(dirname "${ALLSKY_HOME}")/${ALLSKY_INSTALL_DIR}-OLD"
    -
     	# For temporary files or files that can be deleted at reboot.
     	ALLSKY_TMP="${ALLSKY_HOME}/tmp"
     
     	# Central location for all AllSky configuration files.
     	ALLSKY_CONFIG="${ALLSKY_HOME}/config"
     
    -	# Central location for all master repository configuration files.
    -	ALLSKY_REPO="${ALLSKY_HOME}/config_repo"
    -
     	# Holds all the scripts.
     	ALLSKY_SCRIPTS="${ALLSKY_HOME}/scripts"
    +	ALLSKY_UTILITIES="${ALLSKY_SCRIPTS}/utilities"
     
     	# Holds all the binaries.
     	ALLSKY_BIN="${ALLSKY_HOME}/bin"
    @@ -78,12 +77,23 @@ if [[ -z "${ALLSKY_VARIABLE_SET}" ]]; then
     	# Holds a count of continuous "bad" images
     	ALLSKY_BAD_IMAGE_COUNT="${ALLSKY_TMP}/bad_image_count.txt"
     
    -	# Holds the PID of the process that called timelapse.sh
    +	# Holds the number of images left until uploading.
    +	FREQUENCY_FILE="${ALLSKY_TMP}/IMG_UPLOAD_FREQUENCY.txt"
    +
    +	# Holds the PID of the process that called timelapse.sh.
     	ALLSKY_TIMELAPSE_PID_FILE="${ALLSKY_TMP}/timelapse-pid.txt"
     
    -	# Holds information on what the user needs to do after an installation.
    -	ALLSKY_INSTALLATION_LOGS="${ALLSKY_CONFIG}/logs"
    -	POST_INSTALLATION_ACTIONS="${ALLSKY_INSTALLATION_LOGS}/post-installation_actions.txt"
    +	# Camera information:
    +	# List of ALL connected cameras.
    +	CONNECTED_CAMERAS_INFO="${ALLSKY_CONFIG}/connected_cameras.txt"
    +	# Supported RPi cameras
    +	RPi_SUPPORTED_CAMERAS="${ALLSKY_CONFIG}/RPi_cameraInfo.txt"
    +
    +	# Log-related information.
    +	ALLSKY_LOGS="${ALLSKY_CONFIG}/logs"
    +	POST_INSTALLATION_ACTIONS="${ALLSKY_LOGS}/post-installation_actions.txt"
    +	OLD_ALLSKY_REMINDER="${ALLSKY_LOGS}/allsky-OLD_reminder.txt"
    +	CHECK_ALLSKY_LOG="${ALLSKY_LOGS}/checkAllsky.html"
     
     	# Holds temporary list of aborted processes since another one was in progress.
     	ALLSKY_ABORTS_DIR="${ALLSKY_TMP}/aborts"
    @@ -99,6 +109,7 @@ if [[ -z "${ALLSKY_VARIABLE_SET}" ]]; then
     
     	# Base location of the overlay and module configuration and data files.
     	ALLSKY_OVERLAY="${ALLSKY_CONFIG}/overlay"
    +	MY_OVERLAY_TEMPLATES="${ALLSKY_OVERLAY}/myTemplates"
     	ALLSKY_MODULES="${ALLSKY_CONFIG}/modules"
     	ALLSKY_MODULE_LOCATION="/opt/allsky"
     	ALLSKY_EXTRA="${ALLSKY_OVERLAY}/extra"
    @@ -110,19 +121,23 @@ if [[ -z "${ALLSKY_VARIABLE_SET}" ]]; then
     
     	# Allsky version.
     	ALLSKY_VERSION_FILE="${ALLSKY_HOME}/version"
    -	ALLSKY_VERSION="$( head -1 "${ALLSKY_VERSION_FILE}" | tr -d '\n\r' )"
    +	ALLSKY_VERSION="$( < "${ALLSKY_VERSION_FILE}" )"
     
     	# Location of optional allsky-website package.
     	ALLSKY_WEBSITE="${ALLSKY_WEBUI}/allsky"
    -	ALLSKY_WEBSITE_VERSION_FILE="${ALLSKY_WEBSITE}/version"
    -	ALLSKY_WEBSITE_BRANCH_FILE="${ALLSKY_WEBSITE}/branch"
    +	ALLSKY_WEBSITE_CHECKSUM_FILE="${ALLSKY_WEBSITE}/checksums.txt"
     	ALLSKY_WEBSITE_VIEWSETTINGS_DIRECTORY_NAME="viewSettings"
     	ALLSKY_WEBSITE_VIEWSETTINGS_DIRECTORY="${ALLSKY_WEBSITE}/${ALLSKY_WEBSITE_VIEWSETTINGS_DIRECTORY_NAME}"
     	ALLSKY_WEBSITE_CONFIGURATION_NAME="configuration.json"
    -	ALLSKY_REMOTE_WEBSITE_CONFIGURATION_NAME="remote_${ALLSKY_WEBSITE_CONFIGURATION_NAME}"
     	ALLSKY_WEBSITE_CONFIGURATION_FILE="${ALLSKY_WEBSITE}/${ALLSKY_WEBSITE_CONFIGURATION_NAME}"
    +	ALLSKY_REMOTE_WEBSITE_CONFIGURATION_NAME="remote_${ALLSKY_WEBSITE_CONFIGURATION_NAME}"
     	ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE="${ALLSKY_CONFIG}/${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_NAME}"
     
    +	# Areas for users' Allsky-related files that get propogated to new releases.
    +	ALLSKY_MYFILES_NAME="myFiles"
    +	ALLSKY_MYFILES_DIR="${ALLSKY_CONFIG}/${ALLSKY_MYFILES_NAME}"
    +	ALLSKY_WEBSITE_MYFILES_DIR="${ALLSKY_WEBSITE}/${ALLSKY_MYFILES_NAME}"
    +
     	# Holds all the Allsky documentation.
     	ALLSKY_DOCUMENTATION="${ALLSKY_WEBUI}/documentation"
     
    @@ -134,21 +149,54 @@ if [[ -z "${ALLSKY_VARIABLE_SET}" ]]; then
     	ALLSKY_LOG="/var/log/allsky.log"
     	ALLSKY_PERIODIC_LOG="/var/log/allskyperiodic.log"
     
    +	# Status of Allsky
    +	ALLSKY_STATUS="${ALLSKY_CONFIG}/status.json"
    +	ALLSKY_STATUS_INSTALLING="Installing..."
    +	ALLSKY_STATUS_NEVER_RUN="Never Run"
    +	ALLSKY_STATUS_NOT_RUNNING="Not Running"
    +	ALLSKY_STATUS_STARTING="Starting..."
    +	ALLSKY_STATUS_RUNNING="Running"
    +	ALLSKY_STATUS_STOPPED="Stopped (normal)"
    +	ALLSKY_STATUS_ERROR="Stopped (error detected)"
    +	ALLSKY_STATUS_SEE_WEBUI="See the WebUI"
    +
     	# GitHub information - package names, repository, and contents of a file.
     	GITHUB_ROOT="https://github.com/AllskyTeam"
     	GITHUB_RAW_ROOT="https://raw.githubusercontent.com/AllskyTeam"
     	GITHUB_MAIN_BRANCH="master"
     	GITHUB_ALLSKY_PACKAGE="allsky"
    -	GITHUB_WEBSITE_PACKAGE="allsky-website"
     
     	# NAMEs of some configuration files:
     	#	Camera Capabilities - specific to a camera type and model (cc.json)
    -	#	Allsky WebUI settings - specific to a camera type and model (settings.json)
     	#	Allsky WebUI options - created at installation and when camera type changes (options.json)
    -	# They are configuration files so go in ${ALLSKY_CONFIG) like all the other config files.
    +	#	Allsky WebUI settings - specific to a camera type and model (settings.json)
     	CC_FILE="${ALLSKY_CONFIG}/cc.json"
    -	SETTINGS_FILE="${ALLSKY_CONFIG}/settings.json"
     	OPTIONS_FILE="${ALLSKY_CONFIG}/options.json"
    +	SETTINGS_FILE="${ALLSKY_CONFIG}/settings.json"
    +	if [[ -s ${SETTINGS_FILE} ]]; then
    +		# Get the name of the file the websites will look for, and split into name and extension.
    +		FULL_FILENAME="$( jq -r ".filename" "${SETTINGS_FILE}" )"
    +		FILENAME="${FULL_FILENAME%.*}"
    +		EXTENSION="${FULL_FILENAME##*.}"
    +
    +		CAMERA_TYPE="$( jq -r '.cameratype' "${SETTINGS_FILE}" )"
    +		CAMERA_MODEL="$( jq -r '.cameramodel' "${SETTINGS_FILE}" )"
    +		CAMERA_NUMBER="$( jq -r '.cameranumber' "${SETTINGS_FILE}" )"
    +		CAMERA_NUMBER="${CAMERA_NUMBER:-0}"
    +
    +		# So scripts can conditionally output messages.
    +		ALLSKY_DEBUG_LEVEL="$( jq -r '.debuglevel' "${SETTINGS_FILE}" )"
    +	else
    +		# Allsky probably not installed yet so provide defaults.
    +		FILENAME="image"
    +		EXTENSION="jpg"
    +		FULL_FILENAME="${FILENAME}.${EXTENSION}"
    +		ALLSKY_DEBUG_LEVEL=1
    +	fi
    +	ALLSKY_ENV="${ALLSKY_HOME}/env.json"	# holds private info like passwords
    +
    +	IMG_DIR="current/tmp"
    +	CAPTURE_SAVE_DIR="${ALLSKY_TMP}"
     
     	# Python virtual environment
     	ALLSKY_PYTHON_VENV="${ALLSKY_HOME}/venv"
    @@ -161,8 +209,9 @@ if [[ -z "${ALLSKY_VARIABLE_SET}" ]]; then
     	EXIT_ERROR_STOP=100		# unrecoverable error - need user action so stop service
     	EXIT_NO_CAMERA=101		# cannot find camera
     
    -	# Name of the Pi's OS.
    -	PI_OS="$( grep CODENAME /etc/os-release | cut -d= -f2 )"
    +	# Name of the Pi's OS in lowercase.
    +	PI_OS="$( grep VERSION_CODENAME /etc/os-release )"; PI_OS="${PI_OS/VERSION_CODENAME=/}"
    +	PI_OS="${PI_OS,,}"
     
     	# If a user wants to define new variables or assign variables differently,
     	# then load their file if it exists.
    diff --git a/version b/version
    index ea90d1a6b..39e91e852 100644
    --- a/version
    +++ b/version
    @@ -1 +1 @@
    -v2023.05.01_05
    +v2024.12.06
    \ No newline at end of file
    diff --git a/website/install.sh b/website/install.sh
    deleted file mode 100755
    index f87fe8af0..000000000
    --- a/website/install.sh
    +++ /dev/null
    @@ -1,1090 +0,0 @@
    -#!/bin/bash
    -
    -[[ -z ${ALLSKY_HOME} ]] && export ALLSKY_HOME="$(realpath "$(dirname "${BASH_ARGV0}")"/..)"
    -ME="$(basename "${BASH_ARGV0}")"
    -
    -#shellcheck disable=SC2086 source-path=.
    -source "${ALLSKY_HOME}/variables.sh"					|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086 source-path=scripts
    -source "${ALLSKY_SCRIPTS}/functions.sh" 				|| exit ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086 source-path=scripts
    -source "${ALLSKY_SCRIPTS}/installUpgradeFunctions.sh"	|| exit ${ALLSKY_ERROR_STOP}
    -
    -#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/config.sh"			|| exit_installation ${ALLSKY_ERROR_STOP}
    -#shellcheck disable=SC2086,SC1091		# file doesn't exist in GitHub
    -source "${ALLSKY_CONFIG}/ftp-settings.sh"	|| exit_installation ${ALLSKY_ERROR_STOP}
    -
    -TITLE="Allsky Website Installer"
    -
    -# display_msg() will send "log" entries to this file.
    -# DISPLAY_MSG_LOG is used in display_msg()
    -# shellcheck disable=SC2034
    -DISPLAY_MSG_LOG="${ALLSKY_CONFIG}/Website_install.log"
    -
    -
    -####################### functions
    -
    -####
    -# 
    -do_initial_heading()
    -{
    -	MSG="Welcome to the ${TITLE}!\n"
    -
    -if false ; then		# change if/when we have an initial message to display.
    -	if [[ -n ${PRIOR_WEBSITE_TYPE} ]]; then
    -		MSG="${MSG}\nYou will be asked if you want to use the startrails, keograms, and timelapse"
    -		MSG="${MSG}\n(if any) from your prior version of the Allsky Website."
    -		if [[ ${PRIOR_WEBSITE_TYPE} == "new" ]]; then
    -			MSG="${MSG}\nIf so, we will use the prior settings as well."
    -		else
    -			MSG="${MSG}\nIf so, you will need to manually migrate the settings from the"
    -			MSG="${MSG}\nprior website to the new one."
    -		fi
    -	else
    -		MSG="${MSG}\nWhen installation is done you'll need to update the Allsky Website"
    -		MSG="${MSG}\nsettings before using the Website."
    -		MSG="${MSG}\nThe latitude and longitude and some Allsky Map-related settings (if any)"
    -		MSG="${MSG}\nwill be set for you."
    -	fi
    -
    -	MSG="${MSG}\n\nContinue?"
    -	if ! whiptail --title "${TITLE}" --yesno "${MSG}" 25 "${WT_WIDTH}"  3>&1 1>&2 2>&3; then
    -		exit_installation 1
    -	fi
    -fi
    -
    -	##### Display the welcome header
    -	local U2=""
    -	local B=""
    -	if [[ ${DO_REMOTE_WEBSITE} == "true" ]]; then
    -		U2=" for remote servers"
    -	else
    -		U2=""
    -	fi
    -	if [[ ${BRANCH} == "${GITHUB_MAIN_BRANCH}" ]]; then
    -		B=""
    -	else
    -		B=" ${BRANCH}"
    -	fi
    -	display_header "Welcome to the ${TITLE} for${B} version ${NEW_WEBSITE_VERSION}${U2}"
    -}
    -
    -
    -usage_and_exit()
    -{
    -	RET=${1}
    -	if [[ ${RET} == 0 ]]; then
    -		C="${YELLOW}"
    -	else
    -		C="${RED}"
    -	fi
    -	echo
    -	echo -e "${C}Usage: ${ME} [--help] [--debug] [--remote] [--branch name] [--update] [--function name]${NC}"
    -	echo
    -	echo "'--help' displays this message and exits."
    -	echo
    -	echo "'--remote' keeps a copy of a remote server's configuration file on the"
    -	echo "   Pi where it can be updated in the WebUI and uploaded to the server."
    -	echo "   This will have no impact on a local Allsky Website, if installed."
    -	echo
    -	echo "The '--branch' option should only be used when instructed to by an Allsky developer."
    -	echo "  'name' is a valid branch name at ${GITHUB_ROOT}/allsky-website."
    -	echo
    -	echo "'--update' should only be used when instructed to by an Allsky Website page."
    -	echo
    -	echo "'--function' executes the specified function and quits."
    -	echo
    -	# shellcheck disable=SC2086
    -	exit_installation ${RET}
    -}
    -
    -
    -##### Check if the user is trying to download an older Website version.
    -# This could be because they forgot to include "--branch".
    -check_for_old_version()
    -{
    -	local OLD_VERSION="${1}"
    -	local NEW_VERSION="${2}"
    -	local NEW_BRANCH="${3}"
    -
    -	[[ ${NEW_VERSION} == "${OLD_VERSION}" || ${NEW_VERSION} > "${OLD_VERSION}" ]] && return 0
    -
    -	MSG="You are attempting to install an older Website version:"
    -	MSG="${MSG}\n   Current version: ${OLD_VERSION}"
    -	MSG="${MSG}\n   New     version: ${NEW_VERSION}"
    -	if [[ ${NEW_BRANCH} != "${GITHUB_MAIN_BRANCH}" ]]; then
    -		[[ -n ${PRIOR_WEBSITE_BRANCH} ]] && MSG="${MSG}\n   Current branch : ${PRIOR_WEBSITE_BRANCH}"
    -		MSG="${MSG}\n   New     branch : ${NEW_BRANCH}"
    -	fi
    -	MSG="${MSG}\n\nInstalling older versions can cause problems."
    -	MSG="${MSG}\n\nContinue anyhow?"
    -	if whiptail --title "${TITLE}" --defaultno --yesno "${MSG}" 18 80 3>&1 1>&2 2>&3 ; then
    -		MSG="Continuing with older Website version '${NEW_VERSION}' could cause problems."
    -		display_msg --log warning "${MSG}"
    -		return 0
    -	else
    -		MSG="\nInstallation aborted."
    -		MSG="${MSG}\nIf you want to upgrade to another branch, re-run the installation adding"
    -		MSG="${MSG}\n\t--branch BRANCH"
    -		MSG="${MSG}\nto the command line, where 'BRANCH' is the branch you want."
    -		MSG="${MSG}\nThe default branch is '${GITHUB_MAIN_BRANCH}'.\n"
    -		display_msg info "${MSG}"
    -		MSG="User stopped installation due to attempting to install old version (${NEW_VERSION})."
    -		display_msg --logonly info "${MSG}"
    -		return 1
    -	fi
    -}
    -
    -##### Get the current and new versions, taking branches into account.
    -# We haven't downloaded the new version yet so we need to get its version from Git.
    -GOT_VERSIONS_AND_BRANCHES="false"
    -get_versions_and_branches()
    -{
    -	GOT_VERSIONS_AND_BRANCHES="true"
    -
    -	# All new versions have a "versions" file.
    -	# If the user specified a branch, use that, otherwise see if they are running
    -	# a non-production branch.
    -
    -	GITHUB_MAIN_BRANCH_NEW_VERSION="$( get_Git_version "${GITHUB_MAIN_BRANCH}" "allsky-website" )"
    -	if [[ -z ${GITHUB_MAIN_BRANCH_NEW_VERSION} ]]; then
    -		# If this failed we likely won't be able to get the new files either.
    -		display_msg --log error "Unable to determine newest GitHub version."
    -		exit_installation 1
    -	fi
    -
    -	if [[ -z ${USER_SPECIFIED_BRANCH} ]]; then
    -		local MINIMAL_VERSION="v2023"
    -		if [[ ${GITHUB_MAIN_BRANCH_NEW_VERSION:0:5} < "${MINIMAL_VERSION}" ]]; then
    -			MSG="This installer only works with Website versions starting with '${MINIMAL_VERSION}' or newer."
    -			MSG="${MSG}\nYou are attempting to install an old Website version '${GITHUB_MAIN_BRANCH_NEW_VERSION}'."
    -			MSG="${MSG}\n\nIf needed, re-run the installation specifying a newer branch by adding"
    -			MSG="${MSG}\n    --branch BRANCH"
    -			MSG="${MSG}\nto the command line.\n"
    -			display_msg --log error "${MSG}"
    -			whiptail --title "${TITLE}" --msgbox "${MSG}" 12 "${WT_WIDTH}" 3>&1 1>&2 2>&3
    -			exit_installation 1
    -		fi
    -	fi
    -
    -	display_msg "${LOG_TYPE}" info "GITHUB_MAIN_BRANCH_NEW_VERSION=${GITHUB_MAIN_BRANCH_NEW_VERSION}"
    -
    -	if [[ ${DO_REMOTE_WEBSITE} == "true" ]]; then
    -		# Only newer Websites have DO_REMOTE_WEBSITE, so there should be a PRIOR_WEBSITE_VERSION
    -		# if there was a prior remote Website.
    -		if [[ -s ${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE} ]]; then
    -			PRIOR_WEBSITE_VERSION="$( settings .config.AllskyWebsiteVersion "${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}" )"
    -		else
    -			PRIOR_WEBSITE_VERSION="[none]"
    -		fi
    -
    -		if [[ -n ${USER_SPECIFIED_BRANCH} && ${USER_SPECIFIED_BRANCH} != "${GITHUB_MAIN_BRANCH}" ]]; then
    -			NEW_WEBSITE_VERSION="$( get_Git_version "${USER_SPECIFIED_BRANCH}" "allsky-website" )"
    -			if [[ -z ${NEW_WEBSITE_VERSION} ]]; then
    -				MSG="GitHub branch '${USER_SPECIFIED_BRANCH}' does not exist."
    -				MSG="${MSG}\nTry again either with a different branch or without a branch."
    -				display_msg --log error "${MSG}"
    -				exit_installation 1
    -			fi
    -		else
    -			NEW_WEBSITE_VERSION="${GITHUB_MAIN_BRANCH_NEW_VERSION}"
    -		fi
    -
    -		# TODO: Currently no way to determine the branch of a remote website.
    -		# Should put in the configuration file.
    -		PRIOR_WEBSITE_BRANCH=""
    -
    -		if [[ -n ${USER_SPECIFIED_BRANCH} ]]; then
    -			B="${USER_SPECIFIED_BRANCH}"
    -		else
    -			B="${BRANCH}"
    -		fi
    -		if [[ ${PRIOR_WEBSITE_VERSION} != "[none]" ]]; then
    -			check_for_old_version "${PRIOR_WEBSITE_VERSION}" "${NEW_WEBSITE_VERSION}" "${B}" || exit 1
    -		fi
    -
    -		display_msg "${LOG_TYPE}" info "New   remote Website version=${NEW_WEBSITE_VERSION}"
    -		display_msg "${LOG_TYPE}" info "Prior remote Website version=${PRIOR_WEBSITE_VERSION}"
    -
    -		return
    -	fi
    -
    -	if [[ ${PRIOR_WEBSITE_TYPE} == "new" ]]; then
    -		PRIOR_WEBSITE_BRANCH="$( get_branch "${PRIOR_WEBSITE}" )"
    -	fi
    -	PRIOR_WEBSITE_BRANCH="${PRIOR_WEBSITE_BRANCH:-${GITHUB_MAIN_BRANCH}}"
    -
    -	if [[ -n ${USER_SPECIFIED_BRANCH} ]]; then
    -		BRANCH="${USER_SPECIFIED_BRANCH}"
    -	else
    -		# User didn't specify a branch (most common usage).
    -		# See if the user is running a non-production branch.
    -		# Older website types didn't have branches.
    -		BRANCH="${PRIOR_WEBSITE_BRANCH}"
    -	fi
    -
    -	if [[ ${BRANCH} != "${GITHUB_MAIN_BRANCH}" ]]; then
    -		# See if the branch still exists in GitHub.
    -		NEW_WEBSITE_VERSION="$( get_Git_version "${BRANCH}" "allsky-website" )"
    -		if [[ -z ${NEW_WEBSITE_VERSION} ]]; then
    -			MSG="GitHub branch '${BRANCH}' does not exist."
    -			MSG="${MSG}\nTry again either without a branch or with a different branch."
    -			display_msg --log error "${MSG}"
    -			exit_installation 1
    -		fi
    -
    -		NEW_WEBSITE_BRANCH="${BRANCH}"
    -	else
    -		# Using default branch - most common usage.
    -		NEW_WEBSITE_BRANCH="${GITHUB_MAIN_BRANCH}"
    -		NEW_WEBSITE_VERSION="${GITHUB_MAIN_BRANCH_NEW_VERSION}"
    -	fi
    -
    -	if [[ ${PRIOR_WEBSITE_TYPE} == "new" ]]; then
    -		PRIOR_WEBSITE_VERSION="$( get_version "${PRIOR_WEBSITE}/" )"
    -		check_for_old_version "${PRIOR_WEBSITE_VERSION}" "${NEW_WEBSITE_VERSION}" "${NEW_WEBSITE_BRANCH}" || exit 1
    -	else
    -		PRIOR_WEBSITE_VERSION="[none]"
    -	fi
    -
    -
    -	if [[ ${DEBUG} == "true" ]]; then
    -		MSG=""
    -		MSG="${MSG}\n\tCurrent version: ${PRIOR_WEBSITE_VERSION:-Unknown}"
    -		MSG="${MSG}\n\tNew     version: ${NEW_WEBSITE_VERSION}"
    -		if [[ ${BRANCH} != "${GITHUB_MAIN_BRANCH}" ]]; then
    -			MSG="${MSG}\n\tCurrent branch : ${PRIOR_WEBSITE_BRANCH}"
    -			MSG="${MSG}\n\tNew     branch : ${NEW_WEBSITE_BRANCH}"
    -		fi
    -		display_msg --log debug "${MSG}"
    -	fi
    -}
    -
    -
    -##### Make sure the new version is at least as new as the current version,
    -##### i.e., we aren't installing an old version.
    -check_versions() {
    -	[[ ${GOT_VERSIONS_AND_BRANCHES} == "false" ]] && get_versions_and_branches
    -
    -	local CHECK_BRANCH="false"
    -
    -	# We don't know branch for remote Websites so don't check.
    -	# If branches are changing, check.
    -	if [[ ${DO_REMOTE_WEBSITE} == "false" ]]; then
    -		if [[ ${PRIOR_WEBSITE_BRANCH} != "${BRANCH}" ]]; then
    -			CHECK_BRANCH="true"
    -		else
    -			display_msg --log info "Remaining on '${PRIOR_WEBSITE_BRANCH}' branch."
    -		fi
    -	fi
    -
    -	if [[ ${CHECK_BRANCH} == "true" ]]; then
    -		[[ ${USER_SPECIFIED_BRANCH} == "${PRIOR_WEBSITE_BRANCH}" ]] && USER_SPECIFIED_BRANCH=""
    -
    -		# The user didn't specify a branch and there's a prior Website with a non-production
    -		# branch, so ask if they want to use that branch.
    -		MSG="Your prior Allsky Website is running the '${PRIOR_WEBSITE_BRANCH}' branch."
    -		MSG="${MSG}\n\nTypically you should stay with the same branch unless you"
    -		MSG="${MSG} are upgrading to the newest production release or a different branch"
    -		MSG="${MSG} for testing purposes."
    -		if [[ -n ${USER_SPECIFIED_BRANCH} ]]; then
    -			MSG="${MSG}\n\nYou requested upgrading to the '${USER_SPECIFIED_BRANCH}' branch."
    -			MSG="${MSG}\n\nContinue with '${USER_SPECIFIED_BRANCH}'?"
    -			if ! whiptail --title "${TITLE}" --yesno "${MSG}" 18 80 3>&1 1>&2 2>&3; then
    -				MSG="\nInstallation aborted."
    -				MSG="${MSG}\nNot continuing with '${USER_SPECIFIED_BRANCH}' branch."
    -				display_msg info "${MSG}"
    -				display_msg --logonly info "User stopped installation with ${USER_SPECIFIED_BRANCH} branch."
    -				exit_installation 0
    -			fi
    -			MSG="Upgrading to '${USER_SPECIFIED_BRANCH}' branch version ${NEW_WEBSITE_VERSION}"
    -			display_msg --log info "${MSG}"
    -
    -		else
    -			MSG="${MSG}\n\nContinue with the '${PRIOR_WEBSITE_BRANCH}' branch?"
    -			if whiptail --title "${TITLE}" --yesno "${MSG}" 18 80 3>&1 1>&2 2>&3; then
    -				display_msg --log info "User wants to remain on the '${PRIOR_WEBSITE_BRANCH}' branch."
    -			else
    -				MSG="\nInstallation aborted."
    -				MSG="${MSG}\nIf you want to upgrade to another branch, re-run the installation adding"
    -				MSG="${MSG}\n\t--branch BRANCH"
    -				MSG="${MSG}\nto the command line, where 'BRANCH' is the branch you want."
    -				MSG="${MSG}\nThe default branch is '${GITHUB_MAIN_BRANCH}'.\n"
    -				display_msg info "${MSG}"
    -				display_msg --logonly info "User stopped installation with prior ${PRIOR_WEBSITE_BRANCH} branch."
    -				exit_installation 0
    -			fi
    -		fi
    -	fi
    -
    -	if [[ -n ${NEW_WEBSITE_VERSION} && ${PRIOR_WEBSITE_VERSION} != "[none]" && \
    -		     ${NEW_WEBSITE_VERSION}   <  "${PRIOR_WEBSITE_VERSION}" ]]; then
    -		# Unless the version in GitHub is screwed up (i.e., newer one sorts after prior one),
    -		# we should only get here if the user is r
    -		MSG="WARNING: You are trying to install an older version of the Allsky Website!\n"
    -
    -		# If they are changing branches, display both.
    -		local PB=""		# prior branch
    -		local NB=""		# new branch
    -		if [[ ${PRIOR_WEBSITE_BRANCH} != "${NEW_WEBSITE_BRANCH}" ]]; then
    -			PB="(${PRIOR_WEBSITE_BRANCH} branch)"
    -			NB="(${NEW_WEBSITE_BRANCH} branch)"
    -		fi
    -		MSG="${MSG}\nCurrent version: ${PRIOR_WEBSITE_VERSION} ${PB}"
    -		MSG="${MSG}\nNew     version: ${NEW_WEBSITE_VERSION} ${NB}"
    -		MSG="${MSG}\n\nContinue?"
    -		local B=""
    -		[[ ${NEW_WEBSITE_BRANCH} != "${GITHUB_MAIN_BRANCH}" ]] && B="${NEW_WEBSITE_BRANCH} "
    -		if whiptail --title "${TITLE}" --yesno --defaultno "${MSG}" 15 80 3>&1 1>&2 2>&3; then
    -			display_msg --log info "Installing old ${B}version ${NEW_WEBSITE_VERSION}."
    -		else
    -			MSG="\nInstallation aborted."
    -			MSG="${MSG}\nNOT installing old ${NEW_WEBSITE_BRANCH} version ${NEW_WEBSITE_VERSION}."
    -			display_msg --log info "${MSG}"
    -			exit_installation 0
    -		fi
    -	fi
    -}
    -
    -
    -####
    -##### Execute any specified function, then exit.
    -do_function()
    -{
    -	local FUNCTION="${1}"
    -	shift
    -	if ! type "${FUNCTION}" > /dev/null; then
    -		display_msg error "Unknown function: '${FUNCTION}'."
    -		exit_installation 1
    -	fi
    -
    -	${FUNCTION} "$@"
    -	exit_installation $?
    -}
    -
    -
    -##### Modify placeholders.
    -modify_locations() {
    -	display_msg --log progress "Modifying locations in web files."
    -	sed -i -e "s;XX_ALLSKY_CONFIG_XX;${ALLSKY_CONFIG};" "${ALLSKY_WEBSITE}/functions.php"
    -}
    -
    -
    -##### Create and upload a new data.json file and files needed to display settings.
    -upload_data_json_file() {
    -	LOCAL_or_REMOTE="${1}"		# is this for a local or remote Website, or both?
    -	display_msg --log progress "Uploading initial files to ${LOCAL_or_REMOTE} Website(s)."
    -
    -	OUTPUT="$( "${ALLSKY_SCRIPTS}/postData.sh" --websites "${LOCAL_or_REMOTE}" --allFiles 2>&1 )"
    -	if [[ $? -ne 0 || ! -f ${ALLSKY_TMP}/data.json ]]; then
    -		MSG="Unable to upload initial files:"
    -		if echo "${OUTPUT}" | grep --silent "${ALLSKY_WEBSITE_VIEWSETTINGS_DIRECTORY_NAME}: No such file or" ; then
    -			# This should only happen for remote sites, since this installation script
    -			# created the directory for local sites.
    -			MSG="Check that the remote Website has the newest files."
    -			MSG="${MSG}\nThe '${ALLSKY_WEBSITE_VIEWSETTINGS_DIRECTORY_NAME}' directory does not exist."
    -		fi
    -		MSG="${MSG}\n${OUTPUT}"
    -		MSG="${MSG}\nMake sure 'REMOTE_HOST' is set to a valid server or to '' in 'ftp-settings.sh',"
    -		MSG="${MSG}\n then run:   ${ALLSKY_SCRIPTS}/postData.sh"
    -		MSG="${MSG}\nto create and upload a 'data.json' file."
    -		display_msg --log error "${MSG}"
    -		return 1
    -	fi
    -	return 0
    -}
    -
    -
    -##### Set up the location where the website configuration file will go.
    -WEB_CONFIG_FILE=""
    -IMAGE_NAME=""
    -ON_PI=""
    -
    -set_configuration_file_variables() {
    -	[[ -z ${FUNCTION} ]] && display_msg --log progress "Setting Website variables."
    -	if [[ ${DO_REMOTE_WEBSITE} == "true" ]]; then
    -		WEB_CONFIG_FILE="${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}"
    -		IMAGE_NAME="${FULL_FILENAME}"
    -		ON_PI="false"
    -	else
    -		WEB_CONFIG_FILE="${ALLSKY_WEBSITE_CONFIGURATION_FILE}"
    -		#shellcheck disable=SC2153
    -		IMAGE_NAME="/${IMG_DIR}/${FULL_FILENAME}"
    -		ON_PI="true"
    -	fi
    -}
    -
    -
    -##### Check if this is an older configuration file. Return 0 if current, 1 if old.
    -check_for_older_config_file() {
    -	FILE="${1}"
    -
    -	OLD="false"
    -	NEW_CONFIG_VERSION="$( settings .ConfigVersion "${REPO_WEBCONFIG_FILE}" )"
    -	PRIOR_CONFIG_VERSION="$( settings .ConfigVersion "${FILE}" )"
    -	if [[ -z ${PRIOR_CONFIG_VERSION} ]]; then
    -		PRIOR_CONFIG_VERSION="** Unknown **"
    -		OLD="true"
    -	else
    -		[[ ${PRIOR_CONFIG_VERSION} < "${NEW_CONFIG_VERSION}" ]] && OLD="true"
    -	fi
    -	display_msg "${LOG_TYPE}" info "PRIOR_CONFIG_VERSION=${PRIOR_CONFIG_VERSION}, NEW=${NEW_CONFIG_VERSION}"
    -
    -	if [[ ${OLD} == "true" ]]; then
    -		display_msg --log warning "Your ${FILE} is an older version."
    -		MSG="Your    version: ${PRIOR_CONFIG_VERSION}"
    -		MSG="${MSG}\nCurrent version: ${NEW_CONFIG_VERSION}"
    -		MSG="${MSG}\nPlease compare your file to the new one in"
    -		MSG="${MSG}\n${REPO_WEBCONFIG_FILE}"
    -		MSG="${MSG}\nto see what fields have been added, changed, or removed.\n"
    -		display_msg --log notice "${MSG}"
    -		return 1
    -	fi
    -	return 0
    -}
    -
    -
    -##### Create the json configuration file, either for the local machine or a remote one.
    -create_website_configuration_file() {
    -
    -	# If creating for a remote server and there's a local configuration file, use it as the basis.
    -	if [[ ${DO_REMOTE_WEBSITE} == "true" && -f ${ALLSKY_WEBSITE_CONFIGURATION_FILE} ]]; then
    -		display_msg --log progress "Creating default '${WEB_CONFIG_FILE}' file based on the local file."
    -		cp "${ALLSKY_WEBSITE_CONFIGURATION_FILE}" "${WEB_CONFIG_FILE}" || exit_installation 2
    -
    -		# There are only a few things to update.
    -		[[ ${DEBUG} == "true" ]] && display_msg --log debug "Calling updateWebsiteConfig.sh"
    -		# shellcheck disable=SC2086
    -		"${ALLSKY_SCRIPTS}/updateWebsiteConfig.sh" --verbosity silent ${DEBUG_ARG} \
    -			--config "${WEB_CONFIG_FILE}" \
    -			config.imageName		"imageName"		"${IMAGE_NAME}" \
    -			homePage.onPi			"onPi"			"${ON_PI}"
    -
    -		MSG="If you want different settings on the remote Website than what's on the local Website,"
    -		MSG="${MSG}\nedit the remote settings in the WebUI's 'Editor' page."
    -		display_msg notice "${MSG}"
    -		return 0
    -	fi
    -
    -	display_msg --log progress "Creating default '${WEB_CONFIG_FILE}' file."
    -	cp "${REPO_WEBCONFIG_FILE}" "${WEB_CONFIG_FILE}" || exit_installation 2
    -
    -	# Get the array index for the mini-timelapse.
    -	PARENT="homePage.leftSidebar"
    -	FIELD="Mini-timelapse"
    -	INDEX=$(getJSONarrayIndex "${WEB_CONFIG_FILE}" "${PARENT}" "${FIELD}")
    -	if [[ ${INDEX} -ge 0 ]]; then
    -		MINI_TLAPSE_DISPLAY="${PARENT}[${INDEX}].display"
    -		MINI_TLAPSE_URL="${PARENT}[${INDEX}].url"
    -		if [[ ${TIMELAPSE_MINI_IMAGES:-0} -eq 0 ]]; then
    -			MINI_TLAPSE_DISPLAY_VALUE="false"
    -			MINI_TLAPSE_URL_VALUE=""
    -		else
    -			MINI_TLAPSE_DISPLAY_VALUE="true"
    -			if [[ ${DO_REMOTE_WEBSITE} == "true" ]]; then
    -				MINI_TLAPSE_URL_VALUE="mini-timelapse.mp4"
    -			else
    -				#shellcheck disable=SC2153
    -				MINI_TLAPSE_URL_VALUE="/${IMG_DIR}/mini-timelapse.mp4"
    -			fi
    -		fi
    -	else
    -		MSG="Unable to update '${FIELD}' in ${ALLSKY_WEBSITE_CONFIGURATION_FILE}; ignoring."
    -		display_msg --log warning "${MSG}"
    -		# bogus settings that won't do anything
    -		MINI_TLAPSE_DISPLAY="x"
    -		MINI_TLAPSE_URL="x"
    -		MINI_TLAPSE_DISPLAY_VALUE=""
    -		MINI_TLAPSE_URL_VALUE=""
    -	fi
    -
    -	# Convert latitude and longitude to use N, S, E, W.
    -	LATITUDE="$(convertLatLong "${LATITUDE}" "latitude")"
    -	[[ -z ${LATITUDE} ]] && display_msg --log warning "latitude is empty"
    -	LONGITUDE="$(convertLatLong "${LONGITUDE}" "longitude")"
    -	[[ -z ${LONGITUDE} ]] && display_msg --log warning "longitude is empty"
    -
    -	if [[ ${LATITUDE:1,-1} == "S" ]]; then			# last character
    -		AURORAMAP="south"
    -	else
    -		AURORAMAP="north"
    -	fi
    -
    -	LOCATION="$(settings ".location")"
    -	OWNER="$(settings ".owner")"
    -	CAMERA_MODEL="$(settings ".cameraModel")"
    -	CAMERA="${CAMERA_TYPE}${CAMERA_MODEL}"
    -	LENS="$(settings ".lens")"
    -	COMPUTER="$(sed --quiet -e 's/Raspberry Pi/RPi/' -e '/^Model/ s/.*: // p' /proc/cpuinfo)"
    -
    -	# These appeard not to be set for one tester, so put an explicit warning in.
    -	[[ -z ${ALLSKY_VERSION} ]] && display_msg --log warning "AllskyVersion is empty"
    -	[[ -z ${NEW_WEBSITE_VERSION} ]] && display_msg --log warning "AllskyWebsiteVersion is empty"
    -
    -	display_msg "${LOG_TYPE}" debug "Calling updateWebsiteConfig.sh"
    -
    -	# There are some settings we can't determine, like LENS.
    -	# shellcheck disable=SC2086
    -	"${ALLSKY_SCRIPTS}/updateWebsiteConfig.sh" --verbosity silent ${DEBUG_ARG} \
    -		--config "${WEB_CONFIG_FILE}" \
    -		config.imageName			"imageName"			"${IMAGE_NAME}" \
    -		config.latitude				"latitude"			"${LATITUDE}" \
    -		config.longitude			"longitude"			"${LONGITUDE}" \
    -		config.auroraMap			"auroraMap"			"${AURORAMAP}" \
    -		config.location				"location"			"${LOCATION}" \
    -		config.owner				"owner" 			"${OWNER}" \
    -		config.camera				"camera"			"${CAMERA}" \
    -		config.lens					"lens"				"${LENS}" \
    -		config.computer				"computer"			"${COMPUTER}" \
    -		config.AllskyVersion		"AllskyVersion"		"${ALLSKY_VERSION}" \
    -		config.AllskyWebsiteVersion	"AllskyWebsiteVersion" "${NEW_WEBSITE_VERSION}" \
    -		homePage.onPi				"onPi"				"${ON_PI}" \
    -		${MINI_TLAPSE_DISPLAY}		"mini_display"		"${MINI_TLAPSE_DISPLAY_VALUE}" \
    -		${MINI_TLAPSE_URL}			"mini_url"			"${MINI_TLAPSE_URL_VALUE}"
    -}
    -
    -
    -##### Update the Website version in the config file
    -update_version_in_config_file()
    -{
    -	local CONFIG_FILE="${1}"
    -	if [[ ${PRIOR_WEBSITE_VERSION} != "${NEW_WEBSITE_VERSION}" ]]; then
    -		display_msg --log progress "Updating Website version in '${CONFIG_FILE}' to ${NEW_WEBSITE_VERSION}"
    -		# shellcheck disable=SC2086
    -		"${ALLSKY_SCRIPTS}/updateWebsiteConfig.sh" --verbosity silent ${DEBUG_ARG} \
    -			--config "${CONFIG_FILE}" \
    -			config.AllskyWebsiteVersion		"AllskyWebsiteVersion"		"${NEW_WEBSITE_VERSION}"
    -	else
    -		display_msg "${LOG_TYPE}" progress "Website version already at ${NEW_WEBSITE_VERSION} - no need to update."
    -	fi
    -}
    -
    -
    -##### If the user is updating the website, use the prior config file(s).
    -NEEDS_NEW_CONFIGURATION_FILE="true"
    -
    -modify_configuration_variables() {
    -
    -	if [[ ${DEBUG} == "true" ]];then
    -		display_msg --log debug "modify_configuration_variables(): PRIOR_WEBSITE_TYPE=${PRIOR_WEBSITE_TYPE}"
    -	fi
    -	if [[ ${SAVED_PRIOR} == "true" ]]; then
    -		if [[ ${PRIOR_WEBSITE_TYPE} == "new" ]]; then
    -			local C="${PRIOR_WEBSITE}/${ALLSKY_WEBSITE_CONFIGURATION_NAME}"
    -			if [[ -f ${C} ]]; then
    -				if ! json_pp < "${C}" > /dev/null; then
    -					MSG="Configuration file '${C} is corrupted.\nFix, then re-run this installation."
    -					display_msg --log warning "${MSG}"
    -					exit_installation 1
    -				fi
    -
    -				# Check if this is an older configuration file version.
    -				# If it's old check_for_older_config_file() will display a message so we don't need to.
    -				if check_for_older_config_file "${C}" ; then
    -					display_msg --log progress "Restoring prior '${ALLSKY_WEBSITE_CONFIGURATION_NAME}'."
    -					cp "${C}" "${WEB_CONFIG_FILE}" || exit_installation 1
    -
    -					update_version_in_config_file "${WEB_CONFIG_FILE}"
    -
    -					NEEDS_NEW_CONFIGURATION_FILE="false"
    -				# no "else" needed
    -				fi
    -			else
    -				# This "shouldn't" happen with a new-style website, but in case it does...
    -				MSG="Prior Website in ${PRIOR_WEBSITE} had no '${ALLSKY_WEBSITE_CONFIGURATION_NAME}'."
    -				display_msg --log warning "${MSG}"
    -			fi
    -		else
    -			# Old-style Website.
    -			# It's not worth writing the difficult code to merge the old files into the new.
    -
    -			MSG="When installation is done you must manually copy the contents of the prior"
    -			MSG="${MSG}\n   ${PRIOR_WEBSITE}/config.js"
    -			MSG="${MSG}\nand"
    -			MSG="${MSG}\n   ${PRIOR_WEBSITE}/virtualsky.json"
    -			MSG="${MSG}\nfiles into '${ALLSKY_WEBSITE_CONFIGURATION_FILE}'."
    -			MSG="${MSG}\nCheck the Allsky documentation for the meaning of the MANY new options."
    -			display_msg --log notice "${MSG}"
    -		fi
    -	fi
    -
    -	if [[ ${NEEDS_NEW_CONFIGURATION_FILE} == "true" ]]; then
    -		create_website_configuration_file
    -	fi
    -}
    -
    -
    -##### Ask the user if their remote Website is ready for us.
    -check_if_remote_website_ready()
    -{
    -	MSG="Setting up a remote Allsky Website requires that you first:\n"
    -	MSG="${MSG}\n  1. Have Allsky configured and running the way you want it.\n"
    -	MSG="${MSG}\n  2. Upload the Allsky Website files to your remote server.\n"
    -	MSG="${MSG}\n  3. Update 'ftp-settings.sh' using the WebUI's 'Editor' page"
    -	MSG="${MSG}\n     to point to the remote server.\n"
    -	MSG="${MSG}\n  4. Enter the URL of the remote Website into the 'Website URL'"
    -	MSG="${MSG}\n     field in the WebUI's 'Allsky Settings' page,"
    -	MSG="${MSG}\n     even if you are not displaying your Website on the Allsky Map."
    -	MSG="${MSG}\n\n\nHave you completed these steps?"
    -	if ! whiptail --title "${TITLE}" --yesno "${MSG}" 22 80 3>&1 1>&2 2>&3; then
    -		MSG="\nYou need to manually copy the Allsky Website files to your remote server."
    -		MSG="${MSG}\nYou can do that by executing:"
    -		MSG="${MSG}\n   cd /tmp"
    -		MSG="${MSG}\n   git clone ${GITHUB_ROOT}/allsky-website.git allsky"
    -		MSG="${MSG}\nThen upload the 'allsky' directory and all it's contents to the root of your server."
    -		MSG="${MSG}\n\nOnce you have finished that, re-run this installation.\n"
    -		display_msg info "${MSG}"
    -		display_msg --logonly info "User stopped remote installation - not ready."
    -		exit_installation 1
    -	fi
    -}
    -
    -##### Help with a remote Website installation, then exit
    -do_remote_website() {
    -	# Make sure things really are set up, despite what the user said.
    -
    -# TODO: not all protocols require REMOTE_HOST
    -	OK="true"
    -	if [[ -z ${REMOTE_HOST} ]]; then
    -		MSG="The 'REMOTE_HOST' must be set in 'ftp-settings.sh'\n"
    -		MSG="${MSG}in order to do a remote Website installation.\n"
    -		MSG="${MSG}Please set it, the password, and other information, then re-run this installation."
    -		display_msg error "${MSG}"
    -		display_msg --logonly error "REMOTE_HOST not set."
    -		OK="false"
    -	fi
    -	WEBURL="$( settings ".websiteurl" )"
    -	if [[ -z ${WEBURL} ]]; then
    -		MSG="The 'Website URL' setting must be defined in the WebUI\n"
    -		MSG="${MSG}in order to do a remote Website installation.\n"
    -		MSG="${MSG}Please set it then re-run this installation."
    -		display_msg error "${MSG}"
    -		display_msg --logonly error "Website URL not set."
    -		OK="false"
    -	fi
    -
    -	[[ ${OK} == "false" ]] && exit_installation 1
    -
    -	TEST_FILE_NAME="Allsky_upload_test.txt"
    -	TEST_FILE="/tmp/${TEST_FILE_NAME}"
    -	display_msg --log progress "Testing upload to remote Website."
    -	display_msg info "  When done you can remove '${TEST_FILE_NAME}' from your remote server."
    -	echo "This is a test file and can be removed." > "${TEST_FILE}"
    -	if ! RET="$("${ALLSKY_SCRIPTS}/upload.sh" \
    -			"${TEST_FILE}" \
    -			"${IMAGE_DIR}" \
    -			"${TEST_FILE_NAME}" \
    -			"UploadTest")" ; then
    -		MSG="Unable to upload a test file.\n"
    -		display_msg --log error "${MSG}"
    -		display_msg --log info "${RET}"
    -		OK="false"
    -	fi
    -	rm -f "${TEST_FILE}"
    -
    -	[[ ${OK} == "false" ]] && exit_installation 1
    -
    -	# TODO: AUTOMATE: do a git clone into a temp directory, then copy all the files up.
    -	# TODO: Will also need to change the messages above.
    -
    -	# Tell the remote server to check the sanity of the Website.
    -	# This also creates some necessary directories.
    -	display_msg --log progress "Sanity checking remote Website."
    -	[[ ${WEBURL: -1} != "/" ]] && WEBURL="${WEBURL}/"
    -	if [[ ${DEBUG} == "true" ]]; then
    -		D="&debug"
    -	else
    -		D=""
    -	fi
    -	X="$( curl --show-error --silent "${WEBURL}?check=1${D}" )"
    -	if ! echo "${X}" | grep --silent "^SUCCESS$" ; then
    -		MSG="Sanity check of remote Website (${WEBURL}) failed."
    -		MSG2="${MSG}\nYou will need to manually fix."
    -		display_msg warning "${MSG}${MSG2}"
    -		display_msg --logonly warning "${MSG}"
    -		echo -e "${X}"
    -	fi
    -
    -	if [[ -f ${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE} ]]; then
    -		# The user is upgrading a new-style remote Website.
    -		display_msg --log progress "You should continue to configure your remote Allsky Website via the WebUI."
    -
    -		update_version_in_config_file "${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}"
    -
    -		# Check if this is an older configuration file version.
    -		check_for_older_config_file "${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}"
    -	else
    -		# Don't know if the user is upgrading an old-style remote website,
    -		# or they don't even have a remote website.
    -
    -		MSG="You can keep a copy of your remote Website's configuration file on your Pi"
    -		MSG="${MSG}\nso you can easily edit it in the WebUI and have it automatically uploaded."
    -		MSG="${MSG}\n** This is the recommended way of making changes to the configuration **."
    -		MSG="${MSG}\n\nWould you like to do that?"
    -		if (whiptail --title "${TITLE}" --yesno "${MSG}" 15 60 3>&1 1>&2 2>&3); then
    -			create_website_configuration_file
    -
    -			MSG="\nTo edit the remote configuration file, go to the 'Editor' page in the WebUI\n"
    -			MSG="${MSG}and select '${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_NAME} (remote Allsky Website)'.\n"
    -			display_msg info "${MSG}"
    -			display_msg --logonly info "User will use local copy of remote Website config file."
    -		else
    -			# upload_data_json_file needs the remote configuration file to exist or else it thinks
    -			# there isn't a remote site.
    -			(
    -				echo "Do NOT remove or change this file."
    -				echo "It indicates there is a remote Allsky Website although it's not configured from the Pi."
    -			) > "${ALLSKY_REMOTE_WEBSITE_CONFIGURATION_FILE}"
    -
    -			MSG="You need to manually copy '${REPO_WEBCONFIG_FILE}'"
    -			# ALLSKY_WEBSITE_CONFIGURATION_NAME is what it's called on the remote server
    -			MSG="${MSG}to your remote server and rename it to '${ALLSKY_WEBSITE_CONFIGURATION_NAME}',"
    -			MSG="${MSG}then modify it on yuor server."
    -			display_msg warning "${MSG}"
    -			display_msg --logonly info "User will NOT use local copy of remote Website config file."
    -		fi
    -	fi
    -
    -	upload_data_json_file "remote" || exit_installation 2
    -
    -	display_msg --log progress "The Pi portion of the Remote Allsky Website Installation is complete.\n"
    -
    -	exit_installation 0
    -}
    -
    -
    -##### Handle an update to the Website, then exit.
    -do_update() {
    -	# This isn't a true "installation" so don't log anything.
    -	if [[ ! -d ${ALLSKY_WEBSITE} ]]; then
    -		display_msg error " --update specified but no existing website found at '${ALLSKY_WEBSITE}'."
    -		exit 2
    -	fi
    -
    -	modify_locations
    -	upload_data_json_file "local"
    -
    -	display_msg progress "\nUpdate complete!\n"
    -	exit 0
    -}
    -
    -
    -####
    -# See if a prior Allsky Website exists; if so, save its location and type.
    -does_prior_Allsky_Website_exist()
    -{
    -	if [[ -d ${ALLSKY_WEBSITE} ]]; then
    -		# Has a older version of the new-style website.
    -		PRIOR_WEBSITE="${ALLSKY_WEBSITE}"
    -		PRIOR_WEBSITE_TYPE="new"
    -
    -	elif [[ -d ${OLD_WEBUI_LOCATION}/allsky ]]; then
    -		# Has an old-style website.  It is NOT moved.
    -		PRIOR_WEBSITE="${OLD_WEBUI_LOCATION}/allsky"
    -		PRIOR_WEBSITE_TYPE="old"
    -
    -	else
    -		# No prior website
    -		PRIOR_WEBSITE=""
    -		PRIOR_WEBSITE_TYPE=""
    -	fi
    -
    -	if [[ -n ${PRIOR_WEBSITE} ]]; then
    -		# Location we'll move the prior website to.
    -		PRIOR_WEBSITE_OLD="${PRIOR_WEBSITE}-OLD"
    -		if [[ ${PRIOR_WEBSITE_TYPE} == "new" ]]; then
    -			# git will fail if the new directory already exists and has something in it,
    -			# so rename it.
    -			if [[ -d ${PRIOR_WEBSITE_OLD} ]]; then
    -				MSG="A saved copy of a prior Allsky Website already exists in"
    -				MSG="${MSG}\n     ${PRIOR_WEBSITE_OLD}"
    -				MSG="${MSG}\n\nCan only have one saved prior version at a time."
    -				display_msg --log error "${MSG}"
    -				display_msg info "\nRemove or rename that directory and re-run the installation.\n"
    -				exit_installation 3
    -			fi
    -		fi
    -	else
    -		PRIOR_WEBSITE_OLD=""
    -	fi
    -}
    -
    -
    -##### See if they are upgrading the website, and if so, if the prior website was an "old" one.
    -# "old" means in the old location and with the old configuration files.
    -save_prior_website() {
    -	if [[ ${PRIOR_WEBSITE_TYPE} == "new" ]]; then
    -		display_msg --log progress "Moving prior website to '${PRIOR_WEBSITE_OLD}'."
    -		if ! mv "${ALLSKY_WEBSITE}" "${PRIOR_WEBSITE_OLD}" ; then
    -			display_msg --log error "Unable to move prior website."
    -			exit_installation 3
    -		fi
    -		# Now that we've renamed the prior website, update ${PRIOR_WEBSITE}
    -		PRIOR_WEBSITE="${PRIOR_WEBSITE_OLD}"
    -		SAVED_PRIOR="true"
    -
    -	elif [[ ${PRIOR_WEBSITE_TYPE} == "old" ]]; then
    -		SAVED_PRIOR="true"
    -		# Leave the old-style Website where it is since it will no longer be used.
    -
    -	else
    -		SAVED_PRIOR="false"
    -	fi
    -}
    -
    -
    -##### Download the Allsky Website files.
    -download_Allsky_Website() {
    -	local B=""
    -
    -	# Only display if not the default.
    -	if [[ ${BRANCH} != "${GITHUB_MAIN_BRANCH}" ]]; then
    -		B=" from ${BRANCH} branch"
    -	fi
    -
    -	display_msg --log progress "Downloading Allsky Website files${B} into ${ALLSKY_WEBSITE}."
    -	TMP="/tmp/git.install.tmp"
    -	# shellcheck disable=SC2086
    -	git clone -b ${BRANCH} "${GITHUB_ROOT}/allsky-website.git" "${ALLSKY_WEBSITE}" > "${TMP}" 2>&1
    -	if [[ $? -ne 0 ]]; then
    -		display_msg --log error "Unable to get Allsky Website files from git."
    -		display_msg --logonly info "$( cat "${TMP}" )"
    -		cat "${TMP}"
    -		exit_installation 4
    -	fi
    -
    -	# If running non-production branch, save the branch.
    -	[[ ${BRANCH} != "${GITHUB_MAIN_BRANCH}" ]] && echo "${BRANCH}" > "${ALLSKY_WEBSITE_BRANCH_FILE}"
    -}
    -
    -
    -##### Restore prior files.
    -restore_prior_files() {
    -	[[ ${SAVED_PRIOR} == "false" ]] && return
    -
    -	# Each directory will have zero or more images.
    -	# Make sure we do NOT mv any .php files.
    -
    -	D="${PRIOR_WEBSITE}/videos/thumbnails"
    -	[[ -d ${D} ]] && mv "${D}"   "${ALLSKY_WEBSITE}/videos"
    -	count=$(find "${PRIOR_WEBSITE}/videos" -maxdepth 1 -name 'allsky-*' | wc -l)
    -	if [[ ${count} -ge 1 ]]; then
    -		display_msg --log progress "Restoring prior videos."
    -		mv "${PRIOR_WEBSITE}"/videos/allsky-*   "${ALLSKY_WEBSITE}/videos"
    -	else
    -		display_msg "${LOG_TYPE}" info "No prior vidoes to restore."
    -	fi
    -
    -	D="${PRIOR_WEBSITE}/keograms/thumbnails"
    -	[[ -d ${D} ]] && mv "${D}"   "${ALLSKY_WEBSITE}/keograms"
    -	count=$(find "${PRIOR_WEBSITE}/keograms" -maxdepth 1 -name 'keogram-*' | wc -l)
    -	if [[ ${count} -ge 1 ]]; then
    -		display_msg progress "Restoring prior keograms."
    -		mv "${PRIOR_WEBSITE}"/keograms/keogram-*   "${ALLSKY_WEBSITE}/keograms"
    -	else
    -		display_msg "${LOG_TYPE}" info "No prior keograms to restore."
    -	fi
    -
    -	D="${PRIOR_WEBSITE}/startrails/thumbnails"
    -	[[ -d ${D} ]] && mv "${D}"   "${ALLSKY_WEBSITE}/startrails"
    -	count=$(find "${PRIOR_WEBSITE}/startrails" -maxdepth 1 -name 'startrails-*' | wc -l)
    -	if [[ ${count} -ge 1 ]]; then
    -		display_msg progress "Restoring prior startrails."
    -		mv "${PRIOR_WEBSITE}"/startrails/startrails-*   "${ALLSKY_WEBSITE}/startrails"
    -	else
    -		display_msg "${LOG_TYPE}" info "No prior startrails to restore."
    -	fi
    -
    -	D="${PRIOR_WEBSITE}/myImages"
    -	if [[ -d ${D} ]]; then
    -		count=$(find "${D}" | wc -l)
    -		if [[ ${count} -gt 1 ]]; then
    -			display_msg --log progress "Restoring prior 'myImages' directory."
    -			mv "${D}"   "${ALLSKY_WEBSITE}"
    -		fi
    -	else
    -		display_msg "${LOG_TYPE}" info "No prior 'myImages' to restore."
    -	fi
    -
    -	A="analyticsTracking.js"
    -	D="${PRIOR_WEBSITE}/${A}"
    -	if [[ -f ${D} ]]; then
    -		if ! cmp --silent "${D}" "${A}" ; then
    -			display_msg progress "Restoring prior '${A}'."
    -			mv "${D}" "${ALLSKY_WEBSITE}"
    -		fi
    -	else
    -		display_msg "${LOG_TYPE}" info "No prior '${A}' to restore."
    -	fi
    -}
    -
    -
    -##### The webserver needs to be able to update the configuration file and create thumbnails.
    -set_permissions()
    -{
    -	display_msg --log progress "Setting ownership and permissions."
    -	sudo chown -R "${ALLSKY_OWNER}:${WEBSERVER_GROUP}" "${ALLSKY_WEBSITE}"
    -	find "${ALLSKY_WEBSITE}/" -type f -exec chmod 664 {} \;
    -	find "${ALLSKY_WEBSITE}/" -type d -exec chmod 775 {} \;
    -}
    -
    -exit_installation()
    -{
    -	[[ -z ${FUNCTION} ]] && display_msg "${LOG_TYPE}" info "\nENDING INSTALLATON AT $(date).\n"
    -	local E="${1}"
    -	#shellcheck disable=SC2086
    -	[[ ${E} -ge 0 ]] && exit ${E}
    -}
    -
    -
    -####################### main part of program
    -
    -# Check arguments
    -OK="true"
    -HELP="false"
    -DEBUG="false"
    -DEBUG_ARG=""
    -LOG_TYPE="--logonly"	# by default we only log some messages but don't display
    -USER_SPECIFIED_BRANCH=""
    -UPDATE="false"
    -FUNCTION=""
    -DO_REMOTE_WEBSITE="false"
    -while [[ $# -gt 0 ]]; do
    -	ARG="${1}"
    -	case "${ARG}" in
    -		--help)
    -			HELP="true"
    -			;;
    -		--debug)
    -			DEBUG="true"
    -			DEBUG_ARG="${ARG}"		# we can pass this to other scripts
    -			LOG_TYPE="--log"
    -			;;
    -		--branch)
    -			USER_SPECIFIED_BRANCH="${2}"
    -			if [[ ${USER_SPECIFIED_BRANCH} == "" ]]; then
    -				OK="false"
    -			else
    -				shift	# skip over BRANCH
    -			fi
    -			;;
    -		--remote)
    -			DO_REMOTE_WEBSITE="true"
    -			;;
    -		--update)
    -			UPDATE="true"
    -			;;
    -		--function)
    -			FUNCTION="${2}"
    -			shift
    -			;;
    -		*)
    -			display_msg --log error "Unknown argument: '${ARG}'."
    -			OK="false"
    -			;;
    -	esac
    -	shift
    -done
    -
    -[[ -z ${FUNCTION} && ${UPDATE} == "false" ]] && display_msg "${LOG_TYPE}" info "STARTING INSTALLATON AT $(date).\n"
    -
    -[[ ${HELP} == "true" ]] && usage_and_exit 0
    -[[ ${OK} == "false" ]] && usage_and_exit 1
    -
    -
    -# Make sure the settings file isn't corrupted.
    -if ! json_pp < "${SETTINGS_FILE}" > /dev/null; then
    -	display_msg --log error "Settings file '${SETTINGS_FILE} is corrupted.\nFix, then re-run this installation."
    -	exit_installation 1
    -fi
    -
    -LATITUDE="$(settings ".latitude")"
    -LONGITUDE="$(settings ".longitude")"
    -if [[ -z ${LATITUDE} || -z ${LONGITUDE} ]]; then
    -	MSG="Latitude and Longitude must be set in the WebUI before the Allsky Website\ncan be installed."
    -	display_msg --log error "${MSG}"
    -	exit_installation 1
    -fi
    -
    -
    -NEW_WEBSITE_VERSION=""			# version we're upgrading to
    -
    -##### See if there's a prior local Website and if so, set some variables.
    -[[ ${DO_REMOTE_WEBSITE} == "false" ]] && does_prior_Allsky_Website_exist
    -
    -##### Get the current and new Website versions taking branch into account.
    -get_versions_and_branches
    -
    -##### Make sure the remote site is ready for us.  If not ready the function exits.
    -[[ ${DO_REMOTE_WEBSITE} == "true" ]] && check_if_remote_website_ready
    -
    -##### Display the welcome header.
    -[[ -z ${FUNCTION} ]] && do_initial_heading
    -
    -##### Make sure the new version really is new.
    -check_versions
    -
    -##### Set some variables that are used by several functions.
    -set_configuration_file_variables
    -
    -##### Executes the specified function, if any, and exits.
    -[[ -n ${FUNCTION} ]] && do_function "${FUNCTION}"
    -
    -##### Handle remote websites
    -[[ ${DO_REMOTE_WEBSITE} == "true" ]] && do_remote_website		# does not return
    -
    -
    -
    -########################    Everything else is for local install
    -
    -
    -# This should only be done when directed to by the local Alsky Website
    -# when it finds a problem.
    -[ "${UPDATE}" = "true" ]  && do_update		# does not return
    -
    -##### Handle prior website, if any
    -save_prior_website
    -
    -##### Download new Allsky Website files
    -download_Allsky_Website
    -
    -modify_locations
    -modify_configuration_variables
    -upload_data_json_file "local" || exit_installation 1
    -restore_prior_files
    -
    -# Create any directories not created above.
    -mkdir -p \
    -	"${ALLSKY_WEBSITE}/startrails/thumbnails" \
    -	"${ALLSKY_WEBSITE}/keograms/thumbnails" \
    -	"${ALLSKY_WEBSITE}/videos/thumbnails" \
    -	"${ALLSKY_WEBSITE}/myImages"
    -
    -##### Set permissions on files and directories
    -set_permissions
    -
    -
    -echo -en "${GREEN}"
    -display_header "Installation is complete"
    -echo -en "${NC}"
    -
    -
    -if [[ ${SAVED_PRIOR} == "true" ]]; then
    -	MSG="\nYour prior website is in '${PRIOR_WEBSITE}'."
    -	MSG="${MSG}\nAll your prior videos, keograms, and startrails were MOVED to the updated website."
    -	MSG="${MSG}\nAfter you are convinced everything is working, remove your prior version.\n"
    -	display_msg --log info "${MSG}"
    -fi
    -
    -if [[ ${NEEDS_NEW_CONFIGURATION_FILE} == "true" ]]; then
    -	MSG="\nBefore using the website you must edit its configuration by clicking on"
    -	MSG="${MSG}\nthe 'Editor' link in the WebUI, then select the"
    -	MSG="${MSG}\n    ${ALLSKY_WEBSITE_CONFIGURATION_NAME} (local Allsky Website)"
    -	MSG="${MSG}\nentry.  See the 'Installation --> Allsky Website' documentation"
    -	MSG="${MSG}\npage for more information.\n"
    -	display_msg --log info "${MSG}"
    -fi
    -
    -echo
    -exit_installation 0