#                        SQM Display

# Peter Hiscocks, Syscomp Electronic Design
# phiscock@ee.ryerson.ca
# http://www.ee.ryerson.ca/~phiscock

# 8 September 2010

# This program displays and optionally logs data from a Unihedron
# Sky Quality Meter, model SQM-LU

# It requests sky quality readings and displays the information in Tk
# widgets.

# The project and code are described in a pdf document 'A Sky Quality Meter
# Display', available at the author's website, listed above.

# This code should run on a Linux, Windows or Mac system.
# To run this code, first ensure that the Tcl/Tk interpreter is installed on
# your computer. If it's a Linux system, that's probably true. In any case,
# you can download and install Tcl/Tk from the ActiveState web site:
# http://www.activestate.com/activetcl The download is free.

# Linux

# On a Linux machine, plug in the SQM hardware and use the command 'dmesg'
# to verify that the devices is assigned to the serial port '/dev/ttyUSB0'.
# If that's not the case, either unplug other SQM devices or modify the
# source code to match the assignment. In a terminal window type 'wish
# sqm-display.tcl' and the program should start.

# Windows

# Download and install the Tcl language from Activestate.
# http://www.activestate.com/activetcl

# Once the ActiveState Tcl interpreter is downloaded, any file ending in
# '.tcl' should be associated with the Tcl interpreter. Plug in the SQM
# hardware and check device manager (under Ports:COM and LPT) to determine
# the port number assigned.

# Then you can start the program by double-clicking on the source code icon.
# Alternatively, start the Tcl/Tk interpreter by double-clicking on the
# 'wish' icon. A terminal window will appear. In that window, type 'source
# sqm-display.tcl'. The program should start.

# Mac 

# The code has not been tested on a Mac but it should run. Ensure the Tcl
# interpreter is downloaded and installed. Plug in the hardware. Determine
# the port that the hardware is assigned to, which might be something like
# /dev/cua0. Using the Linux and Windows port assignment code as a guide,
# modify the source code to add detection of the port on a Mac. 

#------------------------------------------------------
#License
#------------------------------------------------------
#Copyright 2010 Peter Hiscocks
#phiscock@ee.ryerson.ca

#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; either version 2 of
#the License, or (at your option) any later verison.
#
#This program is distributed in the hope that it will be useful, but
#WITHOUT ANY WARRANTY; without even the implied warranty of
#MECHANTABILITY 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 St, Fifth Floor, Boston, MA 02110-1301
#USA
#---------------------------------------------------------------

# Preliminaries

#=======================================================================
#                     Variable Definitions
#=======================================================================

# Operating system: unix, windows, Darwin
global osType
set osType $tcl_platform(platform)

# Handle of the serial port
global portHandle

# Update type, manual or automatic (timed), initially manual
global updateType
set updateType manualUpdate

# Automatic update interval, initialized to 5 seconds
global updateInterval
set updateInterval 5

# Initialize recording to 'off'
global recordingStatus
set recordingStatus recordingOff	

# Initialize recording to 'not enabled'
global recordingEnabled
set recordingEnabled No

# Set the initial connection status to 'disconnected'
global connectionStatus
set connectionStatus Disconnected
                                        
# Set the initial state of 'console' to hidden.
# Console is only applicable to Windows and Mac systems.
global 	consoleDisplay	
set consoleDisplay consoleHidden

# Uncomment the next line when testing for port detection
# set osType "Darwin"

#=======================================================================
#                      Procedure Definitions
#=======================================================================

#------------------------------------------------------------------ 
# Gather Input Line

# This procedure collects the input message as a single line.
# For the 'while' loop see Welch, 4th ed, page 120.
# It then calls the parser. 
#------------------------------------------------------------------
proc processInput {} {

	global portHandle
	global sqmSentence
	
	while {[gets $portHandle sqmSentence] >= 0} {
	puts "Input Line: $sqmSentence"
	parseSentence
	}
}
#------------------------------------------------------------------ 
# Parse Sentence
# This procedure parses an SQM sentence into variables.

# Manual Report Format
# -------------------
# A typical report format is shown below for a 'manual request'.

# r, 18.60m,0000000004Hz,0000145079c,0000000.315s, 029.6C
# |    |        |             |          |           |
# |    |        |             |          |           ^ Temperature
# |    |        |             |          ^ Period of sensor in seconds (ignore)
# |    |        |             ^ Period of sensor (ignore)
# |    |        ^ Frequency of sensor (ignore)
# |    ^ BMPAS, Reading in magnitudes per square arc-second. Zero indicates overload
# ^'r' to indicate 'report'

# This report is generate with each 'rx' command to the hardware.

# For the method of decomposing a CSV (comma separated variables) string
# into its consituents, see:
# https://www.ibm.com/developerworks/library/l-script-survey/#h6

# For example, if 'last' is a sentence with CSV variables:
# set fieldlist [split $last ,]
#   set a [lindex $fieldlist 0]
#   set b [lindex $fieldlist 1]
#   set c [lindex $fieldlist 2]
#   set d [lindex $fieldlist 3]
# Then 'a', 'b' and so on contain the variables.
#------------------------------------------------------------------
 	
proc parseSentence {} {
	global sqmSentence
	global sentenceList
	global BMPAS
	global temperatureC
	global NELM
	global dateAndTimeResult
	global recordingStatus

	puts "sqmSentence final: $sqmSentence"

# Trim away the leading 'r,' 
	set sqmSentence [string trimleft $sqmSentence r,]
	puts "trimmed string; $sqmSentence"

# Here we parse the SQM sentence into a series of variables in a list
	set sentenceList [split $sqmSentence ,]                                                                        	

# The first item is the BMPAS: Brightness in Magnitudes per Square Arc-Second
	set BMPAS [lindex $sentenceList 0]
	puts "BMPAS before trimming: $BMPAS"
# Trim off the trailing 'm' character from the BMPAS string
	set BMPAS [string trimright $BMPAS m]
	puts "BMPAS after trimming: $BMPAS"
	
# Extract the temperature value
	set temperatureC [lindex $sentenceList 4]
# Trim off any trailing characters, including carriage return
	set temperatureC [string trimright $temperatureC]
# Trim off any leading whitespace	
	set temperatureC [string trimleft $temperatureC]
# Trim off leading zero character
	set temperatureC [string trimleft $temperatureC 0]	
	
# Calculate the NELM (Naked Eye Limiting Magnitude)
# NELM=7.93-5*log(10^(4.316-(Bmpsas/5))+1)
	set NELM [expr 7.93-(5*log10(pow(10,(4.316-($BMPAS/5)))+1))]
# Trim this to 4 most significant characters (including decimal point)
	set NELM [string range $NELM 0 3]

# Read the date and time into a variable
set dateAndTimeResult [clock format [clock seconds]]

# If recording is On, write data to a disk file.
# Writing is further qualified by 'recordingEnabled' which must be 'Yes'
	if {$recordingStatus eq "recordingOn"} {
		storeData
	}
}

#-----------------------------------------------------------------------
# Open the serial port 
#-----------------------------------------------------------------------

# If the operating system is Linux, plug in the hardware and the assigned
#  port can be verified the linux 'dmesg' command. (Look at the last few
#  lines of the output.)
# If the operating system is Windows, plug in the hardware and the port
#  assignment can be determined in Device Manager under Ports: COM and LPT

# To connect, use the spinbox to select the appropriate connection port.
# Then press connect. The display should show 'connected' and manual update
#  should work.

# Linux assigns USB-serial ports in the order in which they are plugged in.
# If there is only one usb-serial device, then the port will be ttyUSB0. If
# some other usb-serial device was plugged in previously, then the SQM port
# assignment will be ttyUSB1. And so on.

# The detection indicator can be fooled. If you have two usb-serial devices
#  plugged in, the routine will happily connect to either one of them and
#  indicate 'connected'. If the selection is the wrong usb-serial device,
#  however, update will not work. Simply select the other usb-serial device
#  and press 'connect' again. Test with the update button.

# If you select a port that does not have a USB-serial device plugged in,
#  when you press 'connect' the indicator will show 'disconnected'.

# This routine sets port configuration to 115200 baud, 8N1, hardware flow
# control off.

# Once opened, the port can be accessed through the global variable
# 'portHandle'.
	
proc openSerialPort { } {

	global osType
	global portName
	global portHandle
	global connectionStatus
	
	puts $osType

	if {$osType=="unix"} then {
		puts "Unix (Linux) operating system"
		set serialPort $portName
		puts "Serial Port set to: $serialPort"
	} elseif {$osType=="windows"} {
       		puts "Windows operating system"
       		set serialPort $portName
       		puts "Serial Port set to: $serialPort"
	} elseif {$osType=="Darwin"} {
		puts "Darwin (Mac OSX) operating system"
		set $serialPort "/dev/cua0"
		puts "Serial Port: $serialPort"
		puts "This might not work, Mac not tested yet"
	} else {
		puts "Unknown Operating System"
	}

	if { [catch {set portHandle [open $serialPort r+]} result] } {
			puts "Unable to open serial port!"
			set connectionStatus Disconnected
		} else {
			puts "Successfully opened Linux or Windows serial port"
			set connectionStatus Connected
		#Configure the serial port
		puts "portHandle: $portHandle"
		if {$osType!="Darwin"} {
			fconfigure $portHandle 			\
				-mode 115200,n,8,1		\
				-blocking 0			\
				-buffering none 		\
				-encoding binary		\
				-translation {binary lf}

			fileevent $portHandle readable {
			processInput
			}

			} else {
			exec stty -f $usbSerial::serialPort 115200 cs8 -parenb -cstopb
			fconfigure $portHandle		\
				-blocking 0		\
				-buffering none		\
				-encoding binary	\
				-translation {binary lf}
			puts "Opened Darwin serial port"
		}
	}
}

#-----------------
#Close Serial Port
#-----------------
#This procedure closes any USB serial port currently open as
#"portHandle". 

proc closeSerialPort {} {
	global portHandle
	
	global connectionStatus	
	
	if {$portHandle != "stdout"} {
		catch { [close $portHandle]}
	}
	set connectionStatus Disconnected
	set portHandle stdout
}
#-----------------------------------------------------------------------
#   Re Connect Serial Port
#-----------------------------------------------------------------------
# This procedure closes and then re-opens the serial port.
# It is called when a spinbox is activated to select a serial port.
# Not used

proc reConnectPort { } {
	puts "Reconnecting Serial Port" 
	closeSerialPort
	openSerialPort
}

#---------------------------------------------------------------
#  Request Update
#---------------------------------------------------------------

# This procedure sends the string 'rx' (no carriage return or linefeed
# required) to the SQM hardware, which causes it to send its 'Manual Report'
# The report is captured by the 'fileevent' handler.

proc requestUpdate {} {
	global portHandle
	puts "portHandle at requestUpdate: $portHandle"
	puts -nonewline $portHandle "rx"
}
#--------------------------------------------------------------------
#  Start Auto Update
#--------------------------------------------------------------------
# This procedure initiates auto updating at the interval set 
# by the variable 'updateInterval'
# The variable 'updateType' enables and disables auto update.
# If updateType = manualUpdate then this is disabled
# If updateType = autoUpdate then this is enabled
# The 'after' command delay is in milliseconds so we need to multiply
#  updateInterval by 1000.

proc startAutoUpdate {} {
	global updateType
	global updateInterval

	puts "starting Auto Update"
	requestUpdate
	set delay [expr $updateInterval*1000] 
	after $delay startAutoUpdate 
}

#---------------------------------------------------------------------
#  Start Auto Update
#---------------------------------------------------------------------
# This procedure cancels auto updating by cancelling the 'after' timer

proc stopAutoUpdate {} {
	puts "stopping Auto Update"
	after cancel startAutoUpdate
}
#------------------------------------------------------------
#                    Start Recording Data

# This procedure opens 'datafileName' for writing the data and sets the
# recording status to ON.
# Then, every time there is an update to the display, we record data to the
# named file.

proc startRecording { } {

	# Name of the data file
	global datafileName

	# Handle for the data file
	global fileID
	
	# Indicator that recording is enabled
	global recordingEnabled
	
	# Number of written data records
	global numRecords
		
	# Establish the default file types
		set types {
		{{CSV Files} {.csv}}
	}
	
	#Get the save file name
	set datafileName [tk_getSaveFile	\
		-filetypes $types	\
		-initialfile "SQM-Data.csv"	\
		-defaultextension .csv]
	
#	Check to see if the file already exists
	if {[file exists $datafileName]} {
		set response [tk_messageBox	\
			-title "Overwrite File?"	\
			-default no	\
			-message "Overwrite file:\n$datafileName"	\
			-type yesno	\
			-icon warning]
		if {$response != "yes"} {
			return
		}
	}
	
	#Open the file for writing.
	# If the 'open' fails, then alert the operator'
	# Else (it succeeds), change the recordingEnabled to Yes
	#  and set the record counter to zero
	if {[catch {open $datafileName w} fileID]} {
		tk_messageBox	\
			-title "File I/O Error"	\
			-default ok	\
			-message "Unable to open file for writing:\n$datafileName"	\
			-type ok	\
			-icon error
		return
		} else {
		set recordingEnabled Yes
		set numRecords 0
		}
	
}
#------------------------------------------------------------------
#                    Stop Recording
#
# The 'catch' takes care of the case where the fileID does not exist

proc stopRecording { } {

	global recordingEnabled
	global fileID
	
	catch {close $fileID}
	set recordingEnabled No
}	
#------------------------------------------------------------------
#                    Store Data to File

# This procedure does the actual writing of data to the disk file. 
# It is called whenever:
#  - recordingStatus is On and recordingEnabled is Yes
#  - and a new data record is received from the hardware.

# The following data is recorded each time in csv format:




# The following may apply for the socket-enabled version of the program.

# This procedure does the actual writing of data to the disk file. It is
# called under two circumstances.

# The selection of these two cases is controlled by the 'extSync' flag
# The flag may have two values: 'Free-Run' and 'Ext-Sync'.

# In Free Run mode, whenever a new update message is received
# In Ext Sync mode, whenever a socket 'sync' message is received from the DVM server.

proc storeData { } {
	# Handle for the currently open file
		global fileID
	# Recording Status (Recording or not)
		global recordingStatus		
	# Recording Enabled (Indicating that a file was successfully opened)
		global recordingEnabled
	# Number of data records
		global numRecords	
	# Brightness, magnitudes per arc-second
		global BMPAS
	# Temperature, C
		global temperatureC
	# Naked Eye Limiting Magnitude
		global NELM
	# Date and Time
		global dateAndTimeResult

	# Assemble the file data to be stored.
		set fileMessage	"$BMPAS,$NELM,$temperatureC,$dateAndTimeResult"
		puts "fileMessage: $fileMessage"
			 
	# Check that recording is enabled and if so, store the data
	#  and increment the number of data records.
	if {$recordingEnabled eq "Yes"} then {
		puts $fileID $fileMessage
		incr numRecords
		puts "Wrote to data storage file: $fileMessage"
		}
}

#=======================================================================
#                      GUI Definitions
#=======================================================================

# A new display window is required if this program is to play nice with an
#  existing Tcl window. Otherwise, it can use the default Tk window.

# For a new display window use the following instructions to create a
# toplevel window .sqm-display.
# Then the frame moves down one level to .sqm-display.container, and so on.
# Create the display window
# toplevel .sqm-display
# wm resizable . 0 0
# wm title . "SQM Display"
# wm protocol .measurements WM_DELETE_WINDOW {destroy .sqm-display}
	
#----------------------------------------------------------------------
#              Create the Heading Frame
#
# This frame contains the labels at the top of the GUI.
#
#----------------------------------------------------------------------
frame .heading		\
	-borderwidth 10

label .heading.mainLabel			\
	-text "Sky Quality Meter SQM-U"		\
	-font {helvetica 14 normal}		\
	-foreground blue

label .heading.secondLabel			\
	-text "Peter Hiscocks"			\
	-font {helvetica 10 normal}		\
	-foreground blue

label .heading.thirdLabel			\
	-text "phiscock@ee.ryerson.ca"		\
	-font {helvetica 10 normal}		\
	-foreground blue

#Main labels for the container
	grid .heading.mainLabel -row 1 -column 1
	grid .heading.secondLabel -row 2 -column 1
	grid .heading.thirdLabel -row 3 -column 1				

pack .heading

#----------------------------------------------------------------------
#              Create the Data Frame
#
# This frame contains the output data for the SQM user.
#
#----------------------------------------------------------------------
#In this case, we used the default Tk window.
#Create a frame for the data displays
frame .container		\
	-borderwidth 10


#Set up the labels and readouts
#------------------------------
#The labels must come *before* the grid description.

#These are the measurement quantity labels
        label .container.sqmlabel               \
                -text "Magnitude per Sq Arcsec" \
                -font {helvetica 16 normal}     \
                -foreground red
	label .container.nelmlabel	\
		-text "Naked Eye Limiting Mag"		\
		-width 20
	label .container.temperatureClabel	\
		-text "Temperature, C"	
	label .container.dateAndTimeLabel	\
		-text "Date and Time"		\
		-width 16

# Fill in the measurements		
 	label .container.sqmresult		\
		-relief sunken			\
		-textvariable BMPAS		\
		-width 22			\
		-font {helvetica 16 normal}     \
		-foreground red
		                
	label .container.nelmresult	\
		-relief sunken	\
		-textvariable NELM	\
		-width 30

	label .container.temperatureCresult	\
		-relief sunken	\
		-textvariable temperatureC \
		-width 30

	label .container.dateAndTimeResult	\
		-relief sunken	\
		-textvariable dateAndTimeResult\
		-width 30
#BMPAS
	grid .container.sqmlabel -row 3 -column 1
	grid .container.sqmresult -row 3 -column 2
#NELM
	grid .container.nelmlabel -row 4 -column 1
	grid .container.nelmresult -row 4 -column 2
#Temperature
	grid .container.temperatureClabel -row 5 -column 1
	grid .container.temperatureCresult -row 5 -column 2
#Date and Time
	grid .container.dateAndTimeLabel -row 6 -column 1
	grid .container.dateAndTimeResult -row 6 -column 2

# Show the GUI
pack .container

#----------------------------------------------------------------------
#              Create the Control Frame
#
# This window contains the various for the SQM display
# Setting of delay for updates
# Enable/Disable recording
#----------------------------------------------------------------------
#In this case, we used the default Tk window.
#Create a frame for the auto measurements
frame .controls			\
	-borderwidth 10

# Entry widget for update interval
	label .controls.updateIntervalLabel	\
		-text "Interval, Sec:"	
	entry .controls.updateIntervalEntry	\
		-width 10			\
		-relief sunken			\
		-bd 2				\
		-textvariable updateInterval	\
		-justify center

# Radiobuttons for Manual/Automatic update
	radiobutton .controls.manualUpdate	\
		-text "Manual Update"		\
		-variable updateType		\
		-value manualUpdate		\
		-anchor w			\
		-command stopAutoUpdate			
		
	radiobutton .controls.autoUpdate	\
		-text "Auto Update"		\
		-variable updateType		\
		-value autoUpdate		\
		-anchor w                       \
		-command startAutoUpdate

# Button for manual request of update
	button .controls.manualUpdateButton	\
		-text "Manual Update"		\
		-background yellow		\
		-command requestUpdate 
		
# Radiobutton for Data Recording On/Off
	radiobutton .controls.recordingOff	\
		-text "Recording Off"		\
		-variable recordingStatus	\
		-value recordingOff		\
		-anchor w			\
		-command stopRecording			
		
	radiobutton .controls.recordingOn	\
		-text "Recording ON"		\
		-variable recordingStatus	\
		-value recordingOn		\
		-anchor w                       \
		-command startRecording

	label .controls.numRecordsLabel	\
		-text "File Records"		\
		-width 16

	label .controls.numRecordsResult	\
		-relief sunken			\
		-textvariable numRecords	\
		-width 10

# Assemble controls into grid



	grid .controls.manualUpdate -row 1 -column 1 -sticky ew
	grid .controls.autoUpdate -row 2 -column 1 -sticky ew
	grid .controls.manualUpdateButton -row 1 -column 3
	grid .controls.updateIntervalLabel -row 2 -column 2
	grid .controls.updateIntervalEntry -row 2 -column 3
	grid .controls.recordingOff -row 5 -column 1 -sticky ew
	grid .controls.recordingOn -row 6 -column 1 -sticky ew
	grid .controls.numRecordsLabel -row 6 -column 2
	grid .controls.numRecordsResult -row 6 -column 3
	
# Show the control frame and contents
pack .controls

#----------------------------------------------------------------------
#              Create the Port Connection Frame
#----------------------------------------------------------------------
#
# This frame contains the port connection controls
# Notice that the COM ports above 9 have a totally wierd syntax. For example, 
#  COM10 is known as '\\.\COM10'. We have to escape the backslash character with
#  another backslash, so in the list this becomes '\\\\.\\COM10'.
# I arbitrarily stopped at COM12, but you can add to the list.
# I advise against using COM1 through COM3, they are often used by other devices.
#
frame .port		\
	-borderwidth 10

	if {$osType=="unix"} then {
		set portOptionList {/dev/ttyUSB0 /dev/ttyUSB1 /dev/ttyUSB2 /dev/ttyUSB3}
	} elseif {$osType=="windows"} {
       		set portOptionList {COM1 COM2 COM3 COM4 COM5 COM6 COM7 COM8 COM9 \\\\.\\COM10 \\\\.\\COM11 \\\\.\\COM12}
	} else {
		puts "Unknown Operating System in port list selection"
		exit
	}
	label .port.portSelectorLabel	\
		-text "Port Selector"		\
		-width 16

	spinbox .port.portSelector		\
		-values $portOptionList		\
		-textvariable portName 	\
		-state readonly			\
		-width 15			

	label .port.connectionStatusDisplayLabel	\
		-text "Status"				\
		-width 16

	label .port.connectionStatusDisplay	\
		-relief sunken			\
		-textvariable connectionStatus	\
		-width 17
		
	button .port.forceConnectButton		\
		-text "Connect"			\
		-background red			\
		-command openSerialPort

# Radiobuttons for console show/hide
	radiobutton .port.consoleEnable		\
		-text "Show Console"		\
		-variable consoleDisplay	\
		-value 	consoleVisible		\
		-anchor w			\
		-command { console show }			
		
	radiobutton .port.consoleDisable	\
		-text "Hide Console"		\
		-variable consoleDisplay	\
		-value consoleHidden		\
		-anchor w                       \
		-command { console hide}

# Show the 'console enable' radiobuttons in Windows and Mac
	if {$osType!="unix"} then { 
	grid .port.consoleEnable -row 1 -column 0
	grid .port.consoleDisable -row 2 -column 0
	}	
	grid .port.portSelectorLabel -row 1 -column 1
	grid .port.portSelector -row 1 -column 2
	grid .port.connectionStatusDisplayLabel -row 2 -column 1
	grid .port.connectionStatusDisplay -row 2 -column 2
	grid .port.forceConnectButton -row 3 -column 2
	
pack .port
                                                
#=======================================================================
#                      Program Starts Here
#=======================================================================

# Open the serial port

#	openSerialPort

# Ensure that buttons light up and entry widget cursors display properly
	tk_focusFollowsMouse	

#Set Up Fileevent
#-----------------

#This code initializes the fileevent handler for data received from the
#instrument. Every time code arrives at the serial port, this routine is
#triggered. It then calls the procedure to inhale the sentence string, which
#in turn calls the parser.
	
#	global portHandle
	
#	fileevent $portHandle readable {
#		processInput

