From 57498e55cd86b2b99a283db9658903b2eb46c376 Mon Sep 17 00:00:00 2001 From: okunze <65952933+okunze@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:19:07 +0000 Subject: [PATCH] Automated Change by GitHub Action --- source/argon-eeprom.sh | 45 + source/argon1v5.sh | 822 +++++++++++++++++ source/argon40.png | Bin 0 -> 700 bytes source/argonone-irdecoder.py | 514 +++++++++++ source/argononeup.sh | 471 ++++++++++ source/firmware/ArgonOne.uf2 | Bin 0 -> 54272 bytes .../argon-rpi-eeprom-config-default.py | 576 ++++++++++++ source/scripts/argonkeyboard.py | 824 ++++++++++++++++++ source/scripts/argonone-irdecoder-libgpiod.py | 525 +++++++++++ source/scripts/argonone-oledconfig.sh | 294 +++++++ source/scripts/argononeup-lidconfig.sh | 114 +++ source/scripts/argononeupd.py | 474 ++++++++++ source/scripts/argononeupd.service | 10 + source/scripts/argononeupduser.service | 10 + source/scripts/argononeupsd.py | 106 +++ source/scripts/argononeupsd.service | 14 + source/scripts/argonupsrtcd.py | 568 ++++++++++++ source/scripts/argonupsrtcd.service | 10 + source/ups/battery_0.png | Bin 0 -> 136 bytes source/ups/battery_1.png | Bin 0 -> 136 bytes source/ups/battery_2.png | Bin 0 -> 136 bytes source/ups/battery_3.png | Bin 0 -> 136 bytes source/ups/battery_4.png | Bin 0 -> 127 bytes source/ups/battery_alert.png | Bin 0 -> 139 bytes source/ups/battery_charging.png | Bin 0 -> 161 bytes source/ups/battery_plug.png | Bin 0 -> 142 bytes source/ups/battery_unknown.png | Bin 0 -> 161 bytes source/ups/upsimg.tar.gz | Bin 0 -> 60389 bytes 28 files changed, 5377 insertions(+) create mode 100644 source/argon-eeprom.sh create mode 100644 source/argon1v5.sh create mode 100644 source/argon40.png create mode 100644 source/argonone-irdecoder.py create mode 100644 source/argononeup.sh create mode 100644 source/firmware/ArgonOne.uf2 create mode 100644 source/scripts/argon-rpi-eeprom-config-default.py create mode 100644 source/scripts/argonkeyboard.py create mode 100644 source/scripts/argonone-irdecoder-libgpiod.py create mode 100644 source/scripts/argonone-oledconfig.sh create mode 100644 source/scripts/argononeup-lidconfig.sh create mode 100644 source/scripts/argononeupd.py create mode 100644 source/scripts/argononeupd.service create mode 100644 source/scripts/argononeupduser.service create mode 100644 source/scripts/argononeupsd.py create mode 100644 source/scripts/argononeupsd.service create mode 100644 source/scripts/argonupsrtcd.py create mode 100644 source/scripts/argonupsrtcd.service create mode 100644 source/ups/battery_0.png create mode 100644 source/ups/battery_1.png create mode 100644 source/ups/battery_2.png create mode 100644 source/ups/battery_3.png create mode 100644 source/ups/battery_4.png create mode 100644 source/ups/battery_alert.png create mode 100644 source/ups/battery_charging.png create mode 100644 source/ups/battery_plug.png create mode 100644 source/ups/battery_unknown.png create mode 100644 source/ups/upsimg.tar.gz diff --git a/source/argon-eeprom.sh b/source/argon-eeprom.sh new file mode 100644 index 0000000..bf99a8e --- /dev/null +++ b/source/argon-eeprom.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +echo "*************" +echo " Argon Setup " +echo "*************" + +# Helper variables +ARGONDOWNLOADSERVER=https://download.argon40.com + +eepromrpiscript="/usr/bin/rpi-eeprom-config" +eepromconfigscript=/dev/shm/argon-eeprom.py + +# Check if Raspbian, Ubuntu, others +CHECKPLATFORM="Others" +if [ -f "/etc/os-release" ] +then + source /etc/os-release + if [ "$ID" = "raspbian" ] + then + CHECKPLATFORM="Raspbian" + elif [ "$ID" = "debian" ] + then + # For backwards compatibility, continue using raspbian + CHECKPLATFORM="Raspbian" + elif [ "$ID" = "ubuntu" ] + then + CHECKPLATFORM="Ubuntu" + fi +fi + +# Check if original eeprom script exists before running +if [ "$CHECKPLATFORM" = "Raspbian" ] +then + if [ -f "$eepromrpiscript" ] + then + sudo apt-get update && sudo apt-get upgrade -y + sudo rpi-eeprom-update + # EEPROM Config Script + sudo wget $ARGONDOWNLOADSERVER/scripts/argon-rpi-eeprom-config-default.py -O $eepromconfigscript --quiet + sudo chmod 755 $eepromconfigscript + sudo $eepromconfigscript + fi +else + echo "Please run this under Raspberry Pi OS" +fi diff --git a/source/argon1v5.sh b/source/argon1v5.sh new file mode 100644 index 0000000..21895a5 --- /dev/null +++ b/source/argon1v5.sh @@ -0,0 +1,822 @@ +#!/bin/bash + +echo "*************" +echo " Argon Setup " +echo "*************" + + +# Check time if need to 'fix' +NEEDSTIMESYNC=0 +LOCALTIME=$(date -u +%s%N | cut -b1-10) +GLOBALTIME=$(curl -s 'http://worldtimeapi.org/api/ip.txt' | grep unixtime | cut -b11-20) +TIMEDIFF=$((GLOBALTIME-LOCALTIME)) + +# about 26hrs, max timezone difference +if [ $TIMEDIFF -gt 100000 ] +then + NEEDSTIMESYNC=1 +fi + + +argon_time_error() { + echo "**********************************************" + echo "* WARNING: Device time seems to be incorrect *" + echo "* This may cause problems during setup. *" + echo "**********************************************" + echo "Possible Network Time Protocol Server issue" + echo "Try running the following to correct:" + echo " curl -k https://download.argon40.com/tools/setntpserver.sh | bash" +} + +if [ $NEEDSTIMESYNC -eq 1 ] +then + argon_time_error +fi + + +# Helper variables +ARGONDOWNLOADSERVER=https://download.argon40.com + +INSTALLATIONFOLDER=/etc/argon + +FLAGFILEV1=$INSTALLATIONFOLDER/flag_v1 + +versioninfoscript=$INSTALLATIONFOLDER/argon-versioninfo.sh + +uninstallscript=$INSTALLATIONFOLDER/argon-uninstall.sh +shutdownscript=/lib/systemd/system-shutdown/argon-shutdown.sh +configscript=$INSTALLATIONFOLDER/argon-config +unitconfigscript=$INSTALLATIONFOLDER/argon-unitconfig.sh +blstrdacconfigscript=$INSTALLATIONFOLDER/argon-blstrdac.sh +statusdisplayscript=$INSTALLATIONFOLDER/argon-status.sh + +setupmode="Setup" + +if [ -f $configscript ] +then + setupmode="Update" + echo "Updating files" +else + sudo mkdir $INSTALLATIONFOLDER + sudo chmod 755 $INSTALLATIONFOLDER +fi + +########## +# Start code lifted from raspi-config +# is_pifive, get_serial_hw and do_serial_hw based on raspi-config + +if [ -e /boot/firmware/config.txt ] ; then + FIRMWARE=/firmware +else + FIRMWARE= +fi +CONFIG=/boot${FIRMWARE}/config.txt + +set_config_var() { + if ! grep -q -E "$1=$2" $3 ; then + echo "$1=$2" | sudo tee -a $3 > /dev/null + fi +} + +is_pifive() { + grep -q "^Revision\s*:\s*[ 123][0-9a-fA-F][0-9a-fA-F]4[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]$" /proc/cpuinfo + return $? +} + + +get_serial_hw() { + if is_pifive ; then + if grep -q -E "dtparam=uart0=off" $CONFIG ; then + echo 1 + elif grep -q -E "dtparam=uart0" $CONFIG ; then + echo 0 + else + echo 1 + fi + else + if grep -q -E "^enable_uart=1" $CONFIG ; then + echo 0 + elif grep -q -E "^enable_uart=0" $CONFIG ; then + echo 1 + elif [ -e /dev/serial0 ] ; then + echo 0 + else + echo 1 + fi + fi +} + +do_serial_hw() { + if [ $1 -eq 0 ] ; then + if is_pifive ; then + set_config_var dtparam=uart0 on $CONFIG + else + set_config_var enable_uart 1 $CONFIG + fi + else + if is_pifive ; then + sudo sed $CONFIG -i -e "/dtparam=uart0.*/d" + else + set_config_var enable_uart 0 $CONFIG + fi + fi +} + +# End code lifted from raspi-config +########## + +# Reuse is_pifive, set_config_var +set_nvme_default() { + if is_pifive ; then + set_config_var dtparam nvme $CONFIG + set_config_var dtparam=pciex1_gen 3 $CONFIG + fi +} +set_maxusbcurrent() { + if is_pifive ; then + #set_config_var max_usb_current 1 $CONFIG + set_config_var usb_max_current_enable 1 $CONFIG + fi +} + + +argon_check_pkg() { + RESULT=$(dpkg-query -W -f='${Status}\n' "$1" 2> /dev/null | grep "installed") + + if [ "" == "$RESULT" ]; then + echo "NG" + else + echo "OK" + fi +} + + +CHECKDEVICE="oneoled" # Hardcoded for argon1oled +# Check if has RTC +# Todo for multiple OS + +#i2cdetect -y 1 | grep -q ' 51 ' +#if [ $? -eq 0 ] +#then +# CHECKDEVICE="eon" +#fi + +CHECKGPIOMODE="libgpiod" # libgpiod or rpigpio + +# Check if Raspbian, Ubuntu, others +CHECKPLATFORM="Others" +CHECKPLATFORMVERSION="" +CHECKPLATFORMVERSIONNUM="" +if [ -f "/etc/os-release" ] +then + source /etc/os-release + if [ "$ID" = "raspbian" ] + then + CHECKPLATFORM="Raspbian" + CHECKPLATFORMVERSION=$VERSION_ID + elif [ "$ID" = "debian" ] + then + # For backwards compatibility, continue using raspbian + CHECKPLATFORM="Raspbian" + CHECKPLATFORMVERSION=$VERSION_ID + elif [ "$ID" = "ubuntu" ] + then + CHECKPLATFORM="Ubuntu" + CHECKPLATFORMVERSION=$VERSION_ID + fi + echo ${CHECKPLATFORMVERSION} | grep -e "\." > /dev/null + if [ $? -eq 0 ] + then + CHECKPLATFORMVERSIONNUM=`cut -d "." -f2 <<< $CHECKPLATFORMVERSION ` + CHECKPLATFORMVERSION=`cut -d "." -f1 <<< $CHECKPLATFORMVERSION ` + fi +fi + +gpiopkg="python3-libgpiod" +if [ "$CHECKGPIOMODE" = "rpigpio" ] +then + if [ "$CHECKPLATFORM" = "Raspbian" ] + then + gpiopkg="raspi-gpio python3-rpi.gpio" + else + gpiopkg="python3-rpi.gpio" + fi +fi + +if [ "$CHECKPLATFORM" = "Raspbian" ] +then + if [ "$CHECKDEVICE" = "eon" ] + then + pkglist=($gpiopkg python3-smbus i2c-tools smartmontools) + elif [ "$CHECKDEVICE" = "oneoled" ] + then + pkglist=($gpiopkg python3-smbus i2c-tools python3-luma.oled) + else + pkglist=($gpiopkg python3-smbus i2c-tools) + fi +else + # Todo handle lgpio + # Ubuntu has serial and i2c enabled + if [ "$CHECKDEVICE" = "eon" ] + then + pkglist=($gpiopkg python3-smbus i2c-tools smartmontools) + elif [ "$CHECKDEVICE" = "oneoled" ] + then + pkglist=($gpiopkg python3-smbus i2c-tools python3-luma.oled) + else + pkglist=($gpiopkg python3-smbus i2c-tools) + fi +fi + +echo "Installing/updating dependencies..." + +for curpkg in ${pkglist[@]}; do + sudo apt-get install -y $curpkg + RESULT=$(argon_check_pkg "$curpkg") + if [ "NG" == "$RESULT" ] + then + echo "********************************************************************" + echo "Please also connect device to the internet and restart installation." + echo "********************************************************************" + exit + fi +done + +echo "Updating configuration ..." + +# Ubuntu Mate for RPi has raspi-config too +command -v raspi-config &> /dev/null +if [ $? -eq 0 ] +then + # Enable i2c and serial + sudo raspi-config nonint do_i2c 0 + if [ ! "$CHECKDEVICE" = "fanhat" ] + then + + if [ "$CHECKPLATFORM" = "Raspbian" ] + then + # bookworm raspi-config prompts user when configuring serial + if [ $(get_serial_hw) -eq 1 ]; then + do_serial_hw 0 + fi + else + sudo raspi-config nonint do_serial 2 + fi + fi +fi + +if [ "$CHECKDEVICE" = "oneoled" ] +then + TMPCONFIGFILE="/dev/shm/tmpconfig.txt" + cat $CONFIG | grep -v 'dtoverlay=dwc2' > $TMPCONFIGFILE + chmod 755 $TMPCONFIGFILE + sudo cp $TMPCONFIGFILE $CONFIG + set_config_var dtoverlay=dwc2,dr_mode host $CONFIG + rm $TMPCONFIGFILE +fi + +# Additional config for pi5 +set_nvme_default +set_maxusbcurrent + +# Fan Setup +basename="argonone" +daemonname=$basename"d" +irconfigscript=$INSTALLATIONFOLDER/${basename}-ir +upsconfigscript=$INSTALLATIONFOLDER/${basename}-upsconfig.sh +fanconfigscript=$INSTALLATIONFOLDER/${basename}-fanconfig.sh +eepromrpiscript="/usr/bin/rpi-eeprom-config" +eepromconfigscript=$INSTALLATIONFOLDER/${basename}-eepromconfig.py +powerbuttonscript=$INSTALLATIONFOLDER/$daemonname.py +unitconfigfile=/etc/argonunits.conf +daemonconfigfile=/etc/$daemonname.conf +daemonfanservice=/lib/systemd/system/$daemonname.service + +daemonhddconfigfile=/etc/${daemonname}-hdd.conf + +echo "Installing/Updating scripts and services ..." + +if [ -f "$eepromrpiscript" ] +then + # EEPROM Config Script + sudo wget $ARGONDOWNLOADSERVER/scripts/argon-rpi-eeprom-config-psu.py -O $eepromconfigscript --quiet + sudo chmod 755 $eepromconfigscript +fi + +if is_pifive +then + # UPS Config Script + sudo wget $ARGONDOWNLOADSERVER/scripts/argonone-upsconfig.sh -O $upsconfigscript --quiet + sudo chmod 755 $upsconfigscript +fi + +for TMPDIRECTORY in "/lib/systemd/system" "/lib/systemd/system-shutdown" +do + sudo mkdir -p "$TMPDIRECTORY" + sudo chmod 755 $TMPDIRECTORY + sudo chown root:root "$TMPDIRECTORY" +done + +# Fan Config Script +sudo wget $ARGONDOWNLOADSERVER/scripts/argonone-fanconfig.sh -O $fanconfigscript --quiet +sudo chmod 755 $fanconfigscript + + +# Fan Daemon/Service Files +sudo wget $ARGONDOWNLOADSERVER/scripts/argononed.py -O $powerbuttonscript --quiet +if [ "$CHECKDEVICE" = "oneoled" ] +then + sudo wget $ARGONDOWNLOADSERVER/scripts/argononeoledd.service -O $daemonfanservice --quiet +else + sudo wget $ARGONDOWNLOADSERVER/scripts/argononed.service -O $daemonfanservice --quiet +fi +sudo chmod 644 $daemonfanservice + +if [ ! "$CHECKDEVICE" = "fanhat" ] +then + # IR Files + sudo wget $ARGONDOWNLOADSERVER/scripts/argonone-irconfig.sh -O $irconfigscript --quiet + sudo chmod 755 $irconfigscript + + if [ ! "$CHECKDEVICE" = "eon" ] + then + sudo wget $ARGONDOWNLOADSERVER/scripts/argon-blstrdac.sh -O $blstrdacconfigscript --quiet + sudo chmod 755 $blstrdacconfigscript + fi +fi + +# Other utility scripts +sudo wget $ARGONDOWNLOADSERVER/scripts/argonstatus.py -O $INSTALLATIONFOLDER/argonstatus.py --quiet +sudo wget $ARGONDOWNLOADSERVER/scripts/argondashboard.py -O $INSTALLATIONFOLDER/argondashboard.py --quiet +sudo wget $ARGONDOWNLOADSERVER/scripts/argon-status.sh -O $statusdisplayscript --quiet +sudo chmod 755 $statusdisplayscript + + +sudo wget $ARGONDOWNLOADSERVER/scripts/argon-versioninfo.sh -O $versioninfoscript --quiet +sudo chmod 755 $versioninfoscript + +sudo wget $ARGONDOWNLOADSERVER/scripts/argonsysinfo.py -O $INSTALLATIONFOLDER/argonsysinfo.py --quiet + +if [ -f "$FLAGFILEV1" ] +then + sudo wget $ARGONDOWNLOADSERVER/scripts/argonregister-v1.py -O $INSTALLATIONFOLDER/argonregister.py --quiet +else + sudo wget $ARGONDOWNLOADSERVER/scripts/argonregister.py -O $INSTALLATIONFOLDER/argonregister.py --quiet +fi + +sudo wget "$ARGONDOWNLOADSERVER/scripts/argonpowerbutton-${CHECKGPIOMODE}.py" -O $INSTALLATIONFOLDER/argonpowerbutton.py --quiet + +sudo wget $ARGONDOWNLOADSERVER/scripts/argononed.py -O $powerbuttonscript --quiet + +sudo wget $ARGONDOWNLOADSERVER/scripts/argon-unitconfig.sh -O $unitconfigscript --quiet +sudo chmod 755 $unitconfigscript + + +# Generate default Fan config file if non-existent +if [ ! -f $daemonconfigfile ]; then + sudo touch $daemonconfigfile + sudo chmod 666 $daemonconfigfile + + echo '#' >> $daemonconfigfile + echo '# Argon Fan Speed Configuration (CPU)' >> $daemonconfigfile + echo '#' >> $daemonconfigfile + echo '55=30' >> $daemonconfigfile + echo '60=55' >> $daemonconfigfile + echo '65=100' >> $daemonconfigfile +fi + +if [ "$CHECKDEVICE" = "eon" ] +then + if [ ! -f $daemonhddconfigfile ]; then + sudo touch $daemonhddconfigfile + sudo chmod 666 $daemonhddconfigfile + + echo '#' >> $daemonhddconfigfile + echo '# Argon Fan Speed Configuration (HDD)' >> $daemonhddconfigfile + echo '#' >> $daemonhddconfigfile + echo '35=30' >> $daemonhddconfigfile + echo '40=55' >> $daemonhddconfigfile + echo '45=100' >> $daemonhddconfigfile + fi +fi + +# Generate default Unit config file if non-existent +if [ ! -f $unitconfigfile ]; then + sudo touch $unitconfigfile + sudo chmod 666 $unitconfigfile + + echo '#' >> $unitconfigfile +fi + + +if [ "$CHECKDEVICE" = "eon" ] +then + # RTC Setup + basename="argoneon" + daemonname=$basename"d" + + rtcconfigfile=/etc/argoneonrtc.conf + rtcconfigscript=$INSTALLATIONFOLDER/${basename}-rtcconfig.sh + daemonrtcservice=/lib/systemd/system/$daemonname.service + rtcdaemonscript=$INSTALLATIONFOLDER/$daemonname.py + + # Generate default RTC config file if non-existent + if [ ! -f $rtcconfigfile ]; then + sudo touch $rtcconfigfile + sudo chmod 666 $rtcconfigfile + + echo '#' >> $rtcconfigfile + echo '# Argon RTC Configuration' >> $rtcconfigfile + echo '#' >> $rtcconfigfile + fi + + # RTC Config Script + sudo wget $ARGONDOWNLOADSERVER/scripts/argoneon-rtcconfig.sh -O $rtcconfigscript --quiet + sudo chmod 755 $rtcconfigscript + + # RTC Daemon/Service Files + sudo wget $ARGONDOWNLOADSERVER/scripts/argonrtc.py -O $INSTALLATIONFOLDER/argonrtc.py --quiet + sudo wget $ARGONDOWNLOADSERVER/scripts/argoneond.py -O $rtcdaemonscript --quiet + sudo wget $ARGONDOWNLOADSERVER/scripts/argoneond.service -O $daemonrtcservice --quiet + sudo chmod 644 $daemonrtcservice +fi + + +if [ "$CHECKDEVICE" = "eon" ] || [ "$CHECKDEVICE" = "oneoled" ] +then + # OLED Setup + basename="argoneon" + daemonname=$basename"d" + + oledconfigscript=$INSTALLATIONFOLDER/${basename}-oledconfig.sh + oledlibscript=$INSTALLATIONFOLDER/${basename}oled.py + oledconfigfile=/etc/argoneonoled.conf + + # Generate default OLED config file if non-existent + if [ ! -f $oledconfigfile ]; then + sudo touch $oledconfigfile + sudo chmod 666 $oledconfigfile + + echo '#' >> $oledconfigfile + echo '# Argon OLED Configuration' >> $oledconfigfile + echo '#' >> $oledconfigfile + echo 'switchduration=30' >> $oledconfigfile + if [ "$CHECKDEVICE" = "eon" ] + then + echo 'screenlist="clock cpu storage raid ram temp ip"' >> $oledconfigfile + else + echo 'screenlist="logo1v5 clock cpu storage ram temp ip"' >> $oledconfigfile + fi + fi + + # OLED Config Script + if [ "$CHECKDEVICE" = "eon" ] + then + sudo wget $ARGONDOWNLOADSERVER/scripts/argoneonoled.py -O $oledlibscript --quiet + sudo wget $ARGONDOWNLOADSERVER/scripts/argoneon-oledconfig.sh -O $oledconfigscript --quiet + else + sudo wget $ARGONDOWNLOADSERVER/scripts/argononeoled.py -O $oledlibscript --quiet + sudo wget $ARGONDOWNLOADSERVER/scripts/argonone-oledconfig.sh -O $oledconfigscript --quiet + fi + sudo chmod 755 $oledconfigscript + + if [ ! -d $INSTALLATIONFOLDER/oled ] + then + sudo mkdir $INSTALLATIONFOLDER/oled + fi + + for binfile in font8x6 font16x12 font32x24 font64x48 font16x8 font24x16 font48x32 bgdefault bgram bgip bgtemp bgcpu bgraid bgstorage bgtime + do + sudo wget $ARGONDOWNLOADSERVER/oled/${binfile}.bin -O $INSTALLATIONFOLDER/oled/${binfile}.bin --quiet + done + + if [ "$CHECKDEVICE" = "oneoled" ] + then + for binfile in logo1v5 + do + sudo wget $ARGONDOWNLOADSERVER/oled/${binfile}.bin -O $INSTALLATIONFOLDER/oled/${binfile}.bin --quiet + done + fi +fi + + +# Argon Uninstall Script +sudo wget $ARGONDOWNLOADSERVER/scripts/argon-uninstall.sh -O $uninstallscript --quiet +sudo chmod 755 $uninstallscript + +# Argon Shutdown script +sudo wget $ARGONDOWNLOADSERVER/scripts/argon-shutdown.sh -O $shutdownscript --quiet +sudo chmod 755 $shutdownscript + +# Argon Config Script +if [ -f $configscript ]; then + sudo rm $configscript +fi +sudo touch $configscript + +# To ensure we can write the following lines +sudo chmod 666 $configscript + +echo '#!/bin/bash' >> $configscript + +echo 'echo "--------------------------"' >> $configscript +echo 'echo "Argon Configuration Tool"' >> $configscript +echo "$versioninfoscript simple" >> $configscript +echo 'echo "--------------------------"' >> $configscript + +echo 'get_number () {' >> $configscript +echo ' read curnumber' >> $configscript +echo ' if [ -z "$curnumber" ]' >> $configscript +echo ' then' >> $configscript +echo ' echo "-2"' >> $configscript +echo ' return' >> $configscript +echo ' elif [[ $curnumber =~ ^[+-]?[0-9]+$ ]]' >> $configscript +echo ' then' >> $configscript +echo ' if [ $curnumber -lt 0 ]' >> $configscript +echo ' then' >> $configscript +echo ' echo "-1"' >> $configscript +echo ' return' >> $configscript +echo ' elif [ $curnumber -gt 100 ]' >> $configscript +echo ' then' >> $configscript +echo ' echo "-1"' >> $configscript +echo ' return' >> $configscript +echo ' fi ' >> $configscript +echo ' echo $curnumber' >> $configscript +echo ' return' >> $configscript +echo ' fi' >> $configscript +echo ' echo "-1"' >> $configscript +echo ' return' >> $configscript +echo '}' >> $configscript +echo '' >> $configscript + +echo 'mainloopflag=1' >> $configscript +echo 'while [ $mainloopflag -eq 1 ]' >> $configscript +echo 'do' >> $configscript +echo ' echo' >> $configscript +echo ' echo "Choose Option:"' >> $configscript +if [ ! "$CHECKDEVICE" = "oneoled" ] +then + echo ' echo " 1. Configure Fan"' >> $configscript +fi + +blstrdacoption=0 + +if [ "$CHECKDEVICE" = "fanhat" ] +then + uninstalloption="4" +else + if [ ! "$CHECKDEVICE" = "oneoled" ] + then + echo ' echo " 2. Configure IR"' >> $configscript + fi + if [ "$CHECKDEVICE" = "eon" ] + then + # ArgonEON Has RTC + echo ' echo " 3. Configure RTC and/or Schedule"' >> $configscript + echo ' echo " 4. Configure OLED"' >> $configscript + uninstalloption="7" + elif [ "$CHECKDEVICE" = "oneoled" ] + then + echo ' echo " 1. Configure OLED"' >> $configscript + echo ' echo " 2. Argon Industria UPS"' >> $configscript + uninstalloption="5" + elif is_pifive + then + echo ' echo " 3. Argon Industria UPS"' >> $configscript + uninstalloption="7" + blstrdacoption=$(($uninstalloption-3)) + echo " echo \" $blstrdacoption. Configure BLSTR DAC (v3/v5 only)\"" >> $configscript + else + uninstalloption="6" + blstrdacoption=$(($uninstalloption-3)) + echo " echo \" $blstrdacoption. Configure BLSTR DAC (v3 only)\"" >> $configscript + fi +fi + +unitsoption=$(($uninstalloption-2)) +echo " echo \" $unitsoption. Configure Units\"" >> $configscript +statusoption=$(($uninstalloption-1)) +echo " echo \" $statusoption. System Information\"" >> $configscript + +echo " echo \" $uninstalloption. Uninstall\"" >> $configscript +echo ' echo ""' >> $configscript +echo ' echo " 0. Exit"' >> $configscript +echo " echo -n \"Enter Number (0-$uninstalloption):\"" >> $configscript +echo ' newmode=$( get_number )' >> $configscript + + +echo ' if [ $newmode -eq 0 ]' >> $configscript +echo ' then' >> $configscript +echo ' echo "Thank you."' >> $configscript +echo ' mainloopflag=0' >> $configscript +echo ' elif [ $newmode -eq 1 ]' >> $configscript +echo ' then' >> $configscript + +# Option 1 +if [ "$CHECKDEVICE" = "eon" ] +then + echo ' echo "Choose Triggers:"' >> $configscript + echo ' echo " 1. CPU Temperature"' >> $configscript + echo ' echo " 2. HDD Temperature"' >> $configscript + echo ' echo ""' >> $configscript + echo ' echo " 0. Cancel"' >> $configscript + echo " echo -n \"Enter Number (0-2):\"" >> $configscript + echo ' submode=$( get_number )' >> $configscript + + echo ' if [ $submode -eq 1 ]' >> $configscript + echo ' then' >> $configscript + echo " $fanconfigscript" >> $configscript + echo ' mainloopflag=0' >> $configscript + echo ' elif [ $submode -eq 2 ]' >> $configscript + echo ' then' >> $configscript + echo " $fanconfigscript hdd" >> $configscript + echo ' mainloopflag=0' >> $configscript + echo ' fi' >> $configscript +elif [ "$CHECKDEVICE" = "oneoled" ] +then + echo " $oledconfigscript" >> $configscript + echo ' mainloopflag=0' >> $configscript +else + echo " $fanconfigscript" >> $configscript + echo ' mainloopflag=0' >> $configscript +fi + +# Options 2 onwards +if [ ! "$CHECKDEVICE" = "fanhat" ] +then + if [ "$CHECKDEVICE" = "oneoled" ] + then + echo ' elif [ $newmode -eq 2 ]' >> $configscript + echo ' then' >> $configscript + echo " $upsconfigscript" >> $configscript + #echo ' mainloopflag=0' >> $configscript + + else + echo ' elif [ $newmode -eq 2 ]' >> $configscript + echo ' then' >> $configscript + echo " $irconfigscript" >> $configscript + #echo ' mainloopflag=0' >> $configscript + + if [ "$CHECKDEVICE" = "eon" ] + then + echo ' elif [ $newmode -eq 3 ]' >> $configscript + echo ' then' >> $configscript + echo " $rtcconfigscript" >> $configscript + #echo ' mainloopflag=0' >> $configscript + echo ' elif [ $newmode -eq 4 ]' >> $configscript + echo ' then' >> $configscript + echo " $oledconfigscript" >> $configscript + #echo ' mainloopflag=0' >> $configscript + elif is_pifive + then + echo ' elif [ $newmode -eq 3 ]' >> $configscript + echo ' then' >> $configscript + echo " $upsconfigscript" >> $configscript + #echo ' mainloopflag=0' >> $configscript + fi + + if [ $blstrdacoption -gt 0 ] + then + echo " elif [ \$newmode -eq $blstrdacoption ]" >> $configscript + echo ' then' >> $configscript + echo " $blstrdacconfigscript" >> $configscript + #echo ' mainloopflag=0' >> $configscript + fi + fi +fi + +# Standard options +echo " elif [ \$newmode -eq $unitsoption ]" >> $configscript +echo ' then' >> $configscript +echo " $unitconfigscript" >> $configscript +#echo ' mainloopflag=0' >> $configscript + +echo " elif [ \$newmode -eq $statusoption ]" >> $configscript +echo ' then' >> $configscript +echo " $statusdisplayscript" >> $configscript + +echo " elif [ \$newmode -eq $uninstalloption ]" >> $configscript +echo ' then' >> $configscript +echo " $uninstallscript" >> $configscript +echo ' mainloopflag=0' >> $configscript +echo ' fi' >> $configscript +echo 'done' >> $configscript + +sudo chmod 755 $configscript + +# Desktop Icon +destfoldername=$USERNAME +if [ -z "$destfoldername" ] +then + destfoldername=$USER +fi +if [ -z "$destfoldername" ] +then + destfoldername="pi" +fi + +shortcutfile="/home/$destfoldername/Desktop/argonone-config.desktop" +if [ -d "/home/$destfoldername/Desktop" ] +then + echo "Creating/Updating Desktop Elements ..." + + terminalcmd="lxterminal --working-directory=/home/$destfoldername/ -t" + if [ -f "/home/$destfoldername/.twisteros.twid" ] + then + terminalcmd="xfce4-terminal --default-working-directory=/home/$destfoldername/ -T" + fi + imagefile=ar1config.png + if [ "$CHECKDEVICE" = "eon" ] + then + imagefile=argoneon.png + fi + sudo wget https://download.argon40.com/$imagefile -O /usr/share/pixmaps/$imagefile --quiet + if [ -f $shortcutfile ]; then + sudo rm $shortcutfile + fi + + # Create Shortcuts + echo "[Desktop Entry]" > $shortcutfile + echo "Name=Argon Configuration" >> $shortcutfile + echo "Comment=Argon Configuration" >> $shortcutfile + echo "Icon=/usr/share/pixmaps/$imagefile" >> $shortcutfile + echo 'Exec='$terminalcmd' "Argon Configuration" -e '$configscript >> $shortcutfile + echo "Type=Application" >> $shortcutfile + echo "Encoding=UTF-8" >> $shortcutfile + echo "Terminal=false" >> $shortcutfile + echo "Categories=None;" >> $shortcutfile + chmod 755 $shortcutfile +fi + +configcmd="$(basename -- $configscript)" + +echo "Initializing Services ..." + +if [ "$setupmode" = "Setup" ] +then + if [ -f "/usr/bin/$configcmd" ] + then + sudo rm /usr/bin/$configcmd + fi + sudo ln -s $configscript /usr/bin/$configcmd + + if [ "$CHECKDEVICE" = "one" ] || [ "$CHECKDEVICE" = "oneoled" ] + then + sudo ln -s $configscript /usr/bin/argonone-config + sudo ln -s $uninstallscript /usr/bin/argonone-uninstall + sudo ln -s $irconfigscript /usr/bin/argonone-ir + elif [ "$CHECKDEVICE" = "fanhat" ] + then + sudo ln -s $configscript /usr/bin/argonone-config + sudo ln -s $uninstallscript /usr/bin/argonone-uninstall + fi + + # Enable and Start Service(s) + sudo systemctl daemon-reload + sudo systemctl enable argononed.service + sudo systemctl start argononed.service + if [ "$CHECKDEVICE" = "eon" ] + then + sudo systemctl enable argoneond.service + sudo systemctl start argoneond.service + fi +else + sudo systemctl daemon-reload + sudo systemctl restart argononed.service + if [ "$CHECKDEVICE" = "eon" ] + then + sudo systemctl restart argoneond.service + fi +fi + + +if [ "$CHECKPLATFORM" = "Raspbian" ] +then + if [ -f "$eepromrpiscript" ] + then + echo "Checking EEPROM ..." + sudo apt-get update && sudo apt-get upgrade -y + sudo rpi-eeprom-update + # EEPROM Config Script + sudo $eepromconfigscript + fi +else + echo "WARNING: EEPROM not updated. Please run this under Raspberry Pi OS" +fi + + +echo "*********************" +echo " $setupmode Completed " +echo "*********************" +$versioninfoscript +echo +echo "Use '$configcmd' to configure device" +echo + + + +if [ $NEEDSTIMESYNC -eq 1 ] +then + argon_time_error +fi + diff --git a/source/argon40.png b/source/argon40.png new file mode 100644 index 0000000000000000000000000000000000000000..d611b362b5f057fd333d548b9c23c7ba2233b026 GIT binary patch literal 700 zcmV;t0z>_YP)V-Y z`}kVuw88)Y0&z)1K~!ko?blIm;~)?QV2~epyo51zcYv2HHY5<{2r5i~-XS*wV3~=KJPg1S3E%*s|DG=ct^#KSd z&>ff|s8LH0Jq(yRD1j=oXn$%5H0-q;11*x$Q^21c+YrESAPXG0+i`0SEWVA>z}}90 zPar-3_hV;fe+}gM7|-b0S6+|dK)r%U0fU|i)%7Bc0(}96o=f%hBAJJgyjp1~z|jw8 zbGxj;KA7+866AjX|8|+uy)wo+h)_ev%dg=bWH>kP6|ig-2Fl4h#|Dhk{tfUKpq+6f zP~N;u#MCkS22z{13a|G@1+nZfWsfOaa>;}Ioe&_x2}GWN?d{$$W$OY^epJBUVs(Og zSMhe>3lm`c2m}DyVW9S5$3gGIc?tCdz11}6Z*vUT5I|rkU?}hlwwCfn3#eaA{l7Qp is#U92ty;C}oAn2aaM PULSETAIL_MAXMICROS_NEC and ctr == 0: + continue + + pulsedata.append((value, pulseLength.microseconds)) + + ctr = ctr + 1 + if pulseLength.microseconds > PULSETAIL_MAXMICROS_NEC: + break + elif ctr > PULSEDATA_MAXCOUNT: + break + + GPIO.cleanup() + # Data is most likely incomplete + if aborted == True: + return [] + elif ctr >= PULSEDATA_MAXCOUNT: + print (" * Unable to decode. Please try again *") + return [] + return pulsedata + + +######################### +# Use LIRC +def lircMode2Task(irlogfile): + os.system("mode2 > "+irlogfile+" 2>&1") + +def startLIRCMode2Logging(irlogfile): + # create a new process + loggerprocess = Process(target=lircMode2Task,args=(irlogfile,)) + loggerprocess.start() + # mode2 will start new process, terminate current + time.sleep(0.1) + loggerprocess.kill() + return True + +def endLIRCMode2Logging(irlogfile): + tmplogfile = irlogfile+".tmp" + os.system("ps | grep ode2 > "+tmplogfile+"") + + if os.path.exists(tmplogfile) == True: + ctr = 0 + fp = open(tmplogfile, "r") + for curline in fp: + if len(curline) > 0: + rowdata = curline.split(" ") + pid = "" + processname = "" + colidx = 0 + while colidx < len(rowdata): + if len(rowdata[colidx]) > 0: + if pid == "": + pid = rowdata[colidx] + else: + processname = rowdata[colidx] + + colidx = colidx + 1 + if processname=="mode2\n": + os.system("kill -9 "+pid) + fp.close() + os.remove(tmplogfile) + return True + +def getLIRCPulseData(): + if haslirclib == False: + print (" * LIRC Module not found, please reboot and try again *") + return [] + + irlogfile = "/dev/shm/lircdecoder.log" + + loggerresult = startLIRCMode2Logging(irlogfile) + if loggerresult == False: + return [(-1, -1)] + + # Wait for log file + logsize = 0 + while logsize == 0: + if os.path.exists(irlogfile) == True: + logsize = os.path.getsize(irlogfile) + if logsize == 0: + time.sleep(0.1) + + # Wait for data to start + newlogsize = logsize + while logsize == newlogsize: + time.sleep(0.1) + newlogsize = os.path.getsize(irlogfile) + + print(" Thank you") + + # Wait for data to stop + while logsize != newlogsize: + logsize = newlogsize + time.sleep(0.1) + newlogsize = os.path.getsize(irlogfile) + + # Finalize File + loggerresult = endLIRCMode2Logging(irlogfile) + if loggerresult == False: + return [(-1, -1)] + + # Decode logfile into Pulse Data + pulsedata = [] + + terminated = False + if os.path.exists(irlogfile) == True: + ctr = 0 + fp = open(irlogfile, "r") + for curline in fp: + if len(curline) > 0: + rowdata = curline.split(" ") + if len(rowdata) == 2: + duration = int(rowdata[1]) + value = 0 + if rowdata[0] == "pulse": + value = 1 + ctr = ctr + 1 + if value == 1 or ctr > 1: + if len(pulsedata) > 0 and duration > PULSELEADER_MINMICROS_NEC: + terminated = True + break + else: + pulsedata.append((value, duration)) + fp.close() + os.remove(irlogfile) + + # Check if terminating pulse detected + if terminated == False: + print (" * Unable to read signal. Please try again *") + return [] + return pulsedata + + +######################### +# Common +irconffile = "/etc/lirc/lircd.conf.d/argon.lircd.conf" + +# I2C +address = 0x1a # I2C Address +addressregister = 0xaa # I2C Address Register + +# Constants +PULSETIMEOUTMS = 1000 +VERIFYTARGET = 3 +PULSEDATA_MAXCOUNT = 200 # Fail safe + +# NEC Protocol Constants +PULSEBIT_MAXMICROS_NEC = 2500 +PULSEBIT_ZEROMICROS_NEC = 1000 + +PULSELEADER_MINMICROS_NEC = 8000 +PULSELEADER_MAXMICROS_NEC = 10000 +PULSETAIL_MAXMICROS_NEC = 12000 + +# Flags +FLAGV1ONLY = False + +try: + if os.path.isfile("/etc/argon/flag_v1"): + FLAGV1ONLY = True +except Exception: + FLAGV1ONLY = False + +# Standard Methods +def getbytestring(pulsedata): + outstring = "" + for curbyte in pulsedata: + tmpstr = hex(curbyte)[2:] + while len(tmpstr) < 2: + tmpstr = "0" + tmpstr + outstring = outstring+tmpstr + return outstring + +def displaybyte(pulsedata): + print (getbytestring(pulsedata)) + + +def pulse2byteNEC(pulsedata): + outdata = [] + bitdata = 1 + curbyte = 0 + bitcount = 0 + for (mode, duration) in pulsedata: + if mode == 1: + continue + elif duration > PULSEBIT_MAXMICROS_NEC: + continue + elif duration > PULSEBIT_ZEROMICROS_NEC: + curbyte = curbyte*2 + 1 + else: + curbyte = curbyte*2 + + bitcount = bitcount + 1 + if bitcount == 8: + outdata.append(curbyte) + curbyte = 0 + bitcount = 0 + # Shouldn't happen, but just in case + if bitcount > 0: + outdata.append(curbyte) + + return outdata + + +def bytecompare(a, b): + idx = 0 + maxidx = len(a) + if maxidx != len(b): + return 1 + while idx < maxidx: + if a[idx] != b[idx]: + return 1 + idx = idx + 1 + return 0 + + +# Main Flow +mode = "custom" +if len(sys.argv) > 1: + mode = sys.argv[1] + +powerdata = [] +buttonlist = ['POWER', 'UP', 'DOWN', 'LEFT', 'RIGHT', + 'VOLUMEUP', 'VOLUMEDOWN', 'OK', 'HOME', 'MENU' + 'BACK'] + +ircodelist = ['00ff39c6', '00ff53ac', '00ff4bb4', '00ff9966', '00ff837c', + '00ff01fe', '00ff817e', '00ff738c', '00ffd32c', '00ffb946', + '00ff09f6'] + +buttonidx = 0 + +if mode == "power": + buttonlist = ['POWER'] + ircodelist = [''] +elif mode == "resetpower": + # Just Set the power so it won't create/update the conf file + buttonlist = ['POWER'] + mode = "default" +elif mode == "custom": + buttonlist = ['POWER', 'UP', 'DOWN', 'LEFT', 'RIGHT', + 'VOLUMEUP', 'VOLUMEDOWN', 'OK', 'HOME', 'MENU' + 'BACK'] + ircodelist = ['', '', '', '', '', + '', '', '', '', '', + ''] + #buttonlist = ['POWER', 'VOLUMEUP', 'VOLUMEDOWN'] + #ircodelist = ['', '', ''] + +if mode == "default": + # To skip the decoding loop + buttonidx = len(buttonlist) + # Set MCU IR code + powerdata = [0x00, 0xff, 0x39, 0xc6] +else: + print ("************************************************") + print ("* WARNING: Current buttons are still active. *") + print ("* Please temporarily assign to a *") + print ("* different button if you plan to *") + print ("* reuse buttons. *") + print ("* e.g. Power Button triggers shutdown *") + print ("* *") + print ("* PROCEED AT YOUR OWN RISK *") + print ("* (Press CTRL+C to abort at any time) *") + print ("************************************************") + +readaborted = False +# decoding loop +while buttonidx < len(buttonlist): + print ("Press your button for "+buttonlist[buttonidx]+" (CTRL+C to abort)") + irprotocol = "" + outdata = [] + verifycount = 0 + readongoing = True + + # Handles NEC protocol Only + while readongoing == True: + # Try GPIO-based reading, if it fails, fallback to LIRC + pulsedata = getGPIOPulseData() + if len(pulsedata) == 1: + if pulsedata[0][0] == -2: + pulsedata = getLIRCPulseData() + + # Aborted + if len(pulsedata) == 1: + if pulsedata[0][0] == -1: + readongoing = False + readaborted = True + buttonidx = len(buttonlist) + break + # Ignore repeat code (NEC) + if len(pulsedata) <= 4: + continue + + # Get leading signal + (mode, duration) = pulsedata[0] + + # Decode IR Protocols + # https://www.sbprojects.net/knowledge/ir/index.php + + if duration >= PULSELEADER_MINMICROS_NEC and duration <= PULSELEADER_MAXMICROS_NEC: + irprotocol = "NEC" + # NEC has 9ms head, +/- 1ms + curdata = pulse2byteNEC(pulsedata) + if len(curdata) > 0: + if verifycount > 0: + if bytecompare(outdata, curdata) == 0: + verifycount = verifycount + 1 + else: + verifycount = 0 + else: + outdata = curdata + verifycount = 1 + + if verifycount >= VERIFYTARGET: + readongoing = False + print ("") + elif verifycount == 0: + print (" * IR code mismatch, please try again *") + elif VERIFYTARGET - verifycount > 1: + print (" Press the button "+ str(VERIFYTARGET - verifycount)+ " more times") + else: + print (" Press the button 1 more time") + else: + print (" * Decoding error. Please try again *") + else: + print (" * Unrecognized signal. Please try again *") + #curdata = pulse2byteLSB(pulsedata) + #displaybyte(curdata) + + # Check for duplicates + newircode = getbytestring(outdata) + if verifycount > 0: + checkidx = 0 + while checkidx < buttonidx and checkidx < len(buttonlist): + if ircodelist[checkidx] == newircode: + print (" Button already assigned. Please try again") + verifycount = 0 + break + checkidx = checkidx + 1 + + # Store code, and power button code if applicable + if verifycount > 0: + if buttonidx == 0: + powerdata = outdata + if buttonidx < len(buttonlist): + # Abort will cause out of bounds + ircodelist[buttonidx] = newircode + #print (buttonlist[buttonidx]+": "+ newircode) + buttonidx = buttonidx + 1 + +if len(powerdata) > 0 and readaborted == False: + # Send to device if completed or reset mode + #print("Writing " + getbytestring(powerdata)) + print("Updating Device...") + try: + bus=smbus.SMBus(1) + except Exception: + try: + # Older version + bus=smbus.SMBus(0) + except Exception: + bus=None + + if bus is None: + print("Device Update Failed: Unable to detect i2c") + else: + # Check for Argon Control Register Support + checkircodewrite = False + argoncyclereg = 0x80 + if FLAGV1ONLY == False: + oldval = bus.read_byte_data(address, argoncyclereg) + newval = oldval + 1 + if newval >= 100: + newval = 98 + bus.write_byte_data(address,argoncyclereg, newval) + time.sleep(1) + newval = bus.read_byte_data(address, argoncyclereg) + + if newval != oldval: + addressregister = 0x82 + checkircodewrite = True + bus.write_byte_data(address,argoncyclereg, oldval) + + bus.write_i2c_block_data(address, addressregister, powerdata) + + + if checkircodewrite == True: + # Check if data was written for devices that support it + print("Verifying ...") + time.sleep(2) + checkircodedata = bus.read_i2c_block_data(address, addressregister, 4) + checkircodecounter = 0 + while checkircodecounter < 4: + # Reuse readaborted flag as indicator if IR code was successfully updated + if checkircodedata[checkircodecounter] != powerdata[checkircodecounter]: + readaborted = True + checkircodecounter = checkircodecounter + 1 + if readaborted == False: + print("Device Update Successful") + else: + print("Verification Failed") + bus.close() + + # Update IR Conf if there are other button + if buttonidx > 1 and readaborted == False: + print("Updating Remote Control Codes...") + fp = open(irconffile, "w") + + # Standard NEC conf header + fp.write("#\n") + fp.write("# Based on NEC templates at http://lirc.sourceforge.net/remotes/nec/\n") + fp.write("# Configured codes based on data gathered\n") + fp.write("#\n") + fp.write("\n") + fp.write("begin remote\n") + fp.write(" name argon\n") + fp.write(" bits 32\n") + fp.write(" flags SPACE_ENC\n") + fp.write(" eps 20\n") + fp.write(" aeps 200\n") + fp.write("\n") + fp.write(" header 8800 4400\n") + fp.write(" one 550 1650\n") + fp.write(" zero 550 550\n") + fp.write(" ptrail 550\n") + fp.write(" repeat 8800 2200\n") + fp.write(" gap 38500\n") + fp.write(" toggle_bit 0\n") + fp.write("\n") + fp.write(" frequency 38000\n") + fp.write("\n") + fp.write(" begin codes\n") + + # Write Key Codes + buttonidx = 1 + while buttonidx < len(buttonlist): + fp.write(" KEY_"+buttonlist[buttonidx]+" 0x"+ircodelist[buttonidx]+"\n") + buttonidx = buttonidx + 1 + fp.write(" end codes\n") + fp.write("end remote\n") + fp.close() + + + diff --git a/source/argononeup.sh b/source/argononeup.sh new file mode 100644 index 0000000..8d667a3 --- /dev/null +++ b/source/argononeup.sh @@ -0,0 +1,471 @@ +#!/bin/bash + +echo "*************" +echo " Argon Setup " +echo "*************" + + +# Check time if need to 'fix' +NEEDSTIMESYNC=0 +LOCALTIME=$(date -u +%s%N | cut -b1-10) +GLOBALTIME=$(curl -s 'http://worldtimeapi.org/api/ip.txt' | grep unixtime | cut -b11-20) +TIMEDIFF=$((GLOBALTIME-LOCALTIME)) + +# about 26hrs, max timezone difference +if [ $TIMEDIFF -gt 100000 ] +then + NEEDSTIMESYNC=1 +fi + + +argon_time_error() { + echo "**********************************************" + echo "* WARNING: Device time seems to be incorrect *" + echo "* This may cause problems during setup. *" + echo "**********************************************" + echo "Possible Network Time Protocol Server issue" + echo "Try running the following to correct:" + echo " curl -k https://download.argon40.com/tools/setntpserver.sh | bash" +} + +if [ $NEEDSTIMESYNC -eq 1 ] +then + argon_time_error +fi + + +# Helper variables +ARGONDOWNLOADSERVER=https://download.argon40.com + +INSTALLATIONFOLDER=/etc/argon +pythonbin="sudo /usr/bin/python3" + +versioninfoscript=$INSTALLATIONFOLDER/argon-versioninfo.sh + +uninstallscript=$INSTALLATIONFOLDER/argon-uninstall.sh +configscript=$INSTALLATIONFOLDER/argon-config +argondashboardscript=$INSTALLATIONFOLDER/argondashboard.py + + +setupmode="Setup" + +if [ -f $configscript ] +then + setupmode="Update" + echo "Updating files" +else + sudo mkdir $INSTALLATIONFOLDER + sudo chmod 755 $INSTALLATIONFOLDER +fi + +########## +# Start code lifted from raspi-config +# set_config_var based on raspi-config + +if [ -e /boot/firmware/config.txt ] ; then + FIRMWARE=/firmware +else + FIRMWARE= +fi +CONFIG=/boot${FIRMWARE}/config.txt + +set_config_var() { + if ! grep -q -E "$1=$2" $3 ; then + echo "$1=$2" | sudo tee -a $3 > /dev/null + fi +} + +# End code lifted from raspi-config +########## + +# Reuse set_config_var +set_nvme_default() { + set_config_var dtparam nvme $CONFIG + set_config_var dtparam=pciex1_gen 3 $CONFIG +} + +set_external_antenna() { + set_config_var dtparam ant2 $CONFIG +} + + +argon_check_pkg() { + RESULT=$(dpkg-query -W -f='${Status}\n' "$1" 2> /dev/null | grep "installed") + + if [ "" == "$RESULT" ]; then + echo "NG" + else + echo "OK" + fi +} + + +CHECKDEVICE="oneup" # Hardcoded for argononeup + +CHECKGPIOMODE="libgpiod" # libgpiod or rpigpio + +# Check if Raspbian, Ubuntu, others +CHECKPLATFORM="Others" +CHECKPLATFORMVERSION="" +CHECKPLATFORMVERSIONNUM="" +if [ -f "/etc/os-release" ] +then + source /etc/os-release + if [ "$ID" = "raspbian" ] + then + CHECKPLATFORM="Raspbian" + CHECKPLATFORMVERSION=$VERSION_ID + elif [ "$ID" = "debian" ] + then + # For backwards compatibility, continue using raspbian + CHECKPLATFORM="Raspbian" + CHECKPLATFORMVERSION=$VERSION_ID + elif [ "$ID" = "ubuntu" ] + then + CHECKPLATFORM="Ubuntu" + CHECKPLATFORMVERSION=$VERSION_ID + fi + echo ${CHECKPLATFORMVERSION} | grep -e "\." > /dev/null + if [ $? -eq 0 ] + then + CHECKPLATFORMVERSIONNUM=`cut -d "." -f2 <<< $CHECKPLATFORMVERSION ` + CHECKPLATFORMVERSION=`cut -d "." -f1 <<< $CHECKPLATFORMVERSION ` + fi +fi + +gpiopkg="python3-libgpiod" +if [ "$CHECKGPIOMODE" = "rpigpio" ] +then + if [ "$CHECKPLATFORM" = "Raspbian" ] + then + gpiopkg="raspi-gpio python3-rpi.gpio" + else + gpiopkg="python3-rpi.gpio" + fi +fi + +pkglist=($gpiopkg python3-smbus i2c-tools python3-evdev ddcutil) + +echo "Installing/updating dependencies..." + +for curpkg in ${pkglist[@]}; do + sudo apt-get install -y $curpkg + RESULT=$(argon_check_pkg "$curpkg") + if [ "NG" == "$RESULT" ] + then + echo "********************************************************************" + echo "Please also connect device to the internet and restart installation." + echo "********************************************************************" + exit + fi +done + +echo "Updating configuration ..." + +# Ubuntu Mate for RPi has raspi-config too +command -v raspi-config &> /dev/null +if [ $? -eq 0 ] +then + # Enable i2c + sudo raspi-config nonint do_i2c 0 +fi + +# Added to enabled NVMe for pi5 +set_nvme_default + +# Fan Setup +basename="argononeup" +daemonname=$basename"d" +eepromrpiscript="/usr/bin/rpi-eeprom-config" +eepromconfigscript=$INSTALLATIONFOLDER/${basename}-eepromconfig.py +daemonscript=$INSTALLATIONFOLDER/$daemonname.py +daemonservice=/lib/systemd/system/$daemonname.service +userdaemonservice=/etc/systemd/user/${daemonname}user.service +daemonconfigfile=/etc/$daemonname.conf + +lidconfigscript=$INSTALLATIONFOLDER/${basename}-lidconfig.sh + + +for TMPDIRECTORY in "/lib/systemd/system" +do + sudo mkdir -p "$TMPDIRECTORY" + sudo chmod 755 $TMPDIRECTORY + sudo chown root:root "$TMPDIRECTORY" +done + +echo "Installing/Updating scripts and services ..." + +if [ ! -f $daemonconfigfile ]; then + # Generate config file for fan speed + sudo touch $daemonconfigfile + sudo chmod 666 $daemonconfigfile + echo '#' >> $daemonconfigfile + echo '# Argon One Up Configuration' >> $daemonconfigfile + echo '#' >> $daemonconfigfile + echo '# lidshutdownsecs number of seconds till shutdown when lid is closed 0 if do nothing' >> $daemonconfigfile + echo 'lidshutdownsecs=300' >> $daemonconfigfile +fi + +# Lid Config Script +sudo wget $ARGONDOWNLOADSERVER/scripts/argononeup-lidconfig.sh -O $lidconfigscript --quiet +sudo chmod 755 $lidconfigscript + + +if [ -f "$eepromrpiscript" ] +then + # EEPROM Config Script + sudo wget $ARGONDOWNLOADSERVER/scripts/argon-rpi-eeprom-config-psu.py -O $eepromconfigscript --quiet + sudo chmod 755 $eepromconfigscript +fi + +# Daemon/Service Files +sudo wget $ARGONDOWNLOADSERVER/scripts/${daemonname}.py -O $daemonscript --quiet +sudo wget $ARGONDOWNLOADSERVER/scripts/${daemonname}.service -O $daemonservice --quiet +sudo chmod 644 $daemonservice + +sudo wget $ARGONDOWNLOADSERVER/scripts/${daemonname}user.service -O $userdaemonservice --quiet +sudo chmod 644 $userdaemonservice + + +# Battery Images +if [ ! -d "$INSTALLATIONFOLDER/ups" ] +then + sudo mkdir $INSTALLATIONFOLDER/ups +fi +sudo wget $ARGONDOWNLOADSERVER/ups/upsimg.tar.gz -O $INSTALLATIONFOLDER/ups/upsimg.tar.gz --quiet +sudo tar xfz $INSTALLATIONFOLDER/ups/upsimg.tar.gz -C $INSTALLATIONFOLDER/ups/ +sudo rm -Rf $INSTALLATIONFOLDER/ups/upsimg.tar.gz + +sudo wget "$ARGONDOWNLOADSERVER/scripts/argonpowerbutton-${CHECKGPIOMODE}.py" -O $INSTALLATIONFOLDER/argonpowerbutton.py --quiet + +sudo wget $ARGONDOWNLOADSERVER/scripts/argonkeyboard.py -O $INSTALLATIONFOLDER/argonkeyboard.py --quiet + +# Other utility scripts +sudo wget $ARGONDOWNLOADSERVER/scripts/argondashboard.py -O $INSTALLATIONFOLDER/argondashboard.py --quiet + +sudo wget $ARGONDOWNLOADSERVER/scripts/argon-versioninfo.sh -O $versioninfoscript --quiet +sudo chmod 755 $versioninfoscript + +sudo wget $ARGONDOWNLOADSERVER/scripts/argonsysinfo.py -O $INSTALLATIONFOLDER/argonsysinfo.py --quiet + +sudo wget $ARGONDOWNLOADSERVER/scripts/argonregister-v1.py -O $INSTALLATIONFOLDER/argonregister.py --quiet + + +# Argon Uninstall Script +sudo wget $ARGONDOWNLOADSERVER/scripts/argon-uninstall.sh -O $uninstallscript --quiet +sudo chmod 755 $uninstallscript + +# Argon Config Script +if [ -f $configscript ]; then + sudo rm $configscript +fi +sudo touch $configscript + +# To ensure we can write the following lines +sudo chmod 666 $configscript + +echo '#!/bin/bash' >> $configscript + +echo 'echo "--------------------------"' >> $configscript +echo 'echo "Argon Configuration Tool"' >> $configscript +echo "$versioninfoscript simple" >> $configscript +echo 'echo "--------------------------"' >> $configscript + +echo 'get_number () {' >> $configscript +echo ' read curnumber' >> $configscript +echo ' if [ -z "$curnumber" ]' >> $configscript +echo ' then' >> $configscript +echo ' echo "-2"' >> $configscript +echo ' return' >> $configscript +echo ' elif [[ $curnumber =~ ^[+-]?[0-9]+$ ]]' >> $configscript +echo ' then' >> $configscript +echo ' if [ $curnumber -lt 0 ]' >> $configscript +echo ' then' >> $configscript +echo ' echo "-1"' >> $configscript +echo ' return' >> $configscript +echo ' elif [ $curnumber -gt 100 ]' >> $configscript +echo ' then' >> $configscript +echo ' echo "-1"' >> $configscript +echo ' return' >> $configscript +echo ' fi ' >> $configscript +echo ' echo $curnumber' >> $configscript +echo ' return' >> $configscript +echo ' fi' >> $configscript +echo ' echo "-1"' >> $configscript +echo ' return' >> $configscript +echo '}' >> $configscript +echo '' >> $configscript + +echo 'mainloopflag=1' >> $configscript +echo 'while [ $mainloopflag -eq 1 ]' >> $configscript +echo 'do' >> $configscript +echo ' echo' >> $configscript +echo ' echo "Choose Option:"' >> $configscript + + +echo ' echo " 1. Get Battery Status"' >> $configscript +echo ' echo " 2. Configure Lid Behavior"' >> $configscript + + +uninstalloption="4" + +statusoption=$(($uninstalloption-1)) +echo " echo \" $statusoption. Dashboard\"" >> $configscript + +echo " echo \" $uninstalloption. Uninstall\"" >> $configscript +echo ' echo ""' >> $configscript +echo ' echo " 0. Exit"' >> $configscript +echo " echo -n \"Enter Number (0-$uninstalloption):\"" >> $configscript +echo ' newmode=$( get_number )' >> $configscript + + + +echo ' if [ $newmode -eq 0 ]' >> $configscript +echo ' then' >> $configscript +echo ' echo "Thank you."' >> $configscript +echo ' mainloopflag=0' >> $configscript +echo ' elif [ $newmode -eq 1 ]' >> $configscript +echo ' then' >> $configscript + +# Option 1 +echo " $pythonbin $daemonscript GETBATTERY" >> $configscript + +echo ' elif [ $newmode -eq 2 ]' >> $configscript +echo ' then' >> $configscript + +# Option 2 +echo " $lidconfigscript" >> $configscript + +# Standard options +echo " elif [ \$newmode -eq $statusoption ]" >> $configscript +echo ' then' >> $configscript +echo " $pythonbin $argondashboardscript" >> $configscript + +echo " elif [ \$newmode -eq $uninstalloption ]" >> $configscript +echo ' then' >> $configscript +echo " $uninstallscript" >> $configscript +echo ' mainloopflag=0' >> $configscript +echo ' fi' >> $configscript +echo 'done' >> $configscript + +sudo chmod 755 $configscript + +# Desktop Icon +destfoldername=$USERNAME +if [ -z "$destfoldername" ] +then + destfoldername=$USER +fi +if [ -z "$destfoldername" ] +then + destfoldername="pi" +fi + +shortcutfile="/home/$destfoldername/Desktop/argononeup.desktop" +if [ -d "/home/$destfoldername/Desktop" ] +then + echo "Creating/Updating Desktop Elements ..." + + terminalcmd="lxterminal --working-directory=/home/$destfoldername/ -t" + if [ -f "/home/$destfoldername/.twisteros.twid" ] + then + terminalcmd="xfce4-terminal --default-working-directory=/home/$destfoldername/ -T" + fi + imagefile=argon40.png + sudo wget https://download.argon40.com/$imagefile -O /etc/argon/$imagefile --quiet + if [ -f $shortcutfile ]; then + sudo rm $shortcutfile + fi + + # Create Shortcuts + echo "[Desktop Entry]" > $shortcutfile + echo "Name=Argon Configuration" >> $shortcutfile + echo "Comment=Argon Configuration" >> $shortcutfile + echo "Icon=/etc/argon/$imagefile" >> $shortcutfile + echo 'Exec='$terminalcmd' "Argon Configuration" -e '$configscript >> $shortcutfile + echo "Type=Application" >> $shortcutfile + echo "Encoding=UTF-8" >> $shortcutfile + echo "Terminal=false" >> $shortcutfile + echo "Categories=None;" >> $shortcutfile + chmod 755 $shortcutfile +fi + +configcmd="$(basename -- $configscript)" + +echo "Initializing Services ..." + +# Force remove lock files +sudo rm -f /dev/shm/argononeupkeyboardlock.txt +sudo rm -f /dev/shm/argononeupkeyboardlock.txt.a + +if [ "$setupmode" = "Setup" ] +then + if [ -f "/usr/bin/$configcmd" ] + then + sudo rm /usr/bin/$configcmd + fi + sudo ln -s $configscript /usr/bin/$configcmd + + # Enable and Start Service(s) + sudo systemctl daemon-reload + sudo systemctl enable argononeupd.service + sudo systemctl start argononeupd.service +else + sudo systemctl daemon-reload + sudo systemctl restart argononeupd.service +fi + +# Enable and Start User Service(s) +for tmpuser in `awk -F: '{ if ($3 >= 1000) print $1 }' /etc/passwd` +do + if [ "$tmpuser" != "nobody" ] + then + if [ "$setupmode" = "Setup" ] + then + sudo -u "$tmpuser" systemctl --user enable argononeupduser.service + sudo -u "$tmpuser" systemctl --user start argononeupduser.service + else + sudo -u "$tmpuser" systemctl --user restart argononeupduser.service + fi + fi +done + +# Current user / fallback +if [ "$setupmode" = "Setup" ] +then + systemctl --user enable argononeupduser.service + systemctl --user start argononeupduser.service +else + systemctl --user restart argononeupduser.service +fi + +if [ "$CHECKPLATFORM" = "Raspbian" ] +then + if [ -f "$eepromrpiscript" ] + then + echo "Checking EEPROM ..." + sudo apt-get update && sudo apt-get upgrade -y + sudo rpi-eeprom-update + # EEPROM Config Script + sudo $eepromconfigscript + fi +else + echo "WARNING: EEPROM not updated. Please run this under Raspberry Pi OS" +fi + + +echo "*********************" +echo " $setupmode Completed " +echo "*********************" +$versioninfoscript +echo +echo "Use '$configcmd' to configure device" +echo + + + +if [ $NEEDSTIMESYNC -eq 1 ] +then + argon_time_error +fi + diff --git a/source/firmware/ArgonOne.uf2 b/source/firmware/ArgonOne.uf2 new file mode 100644 index 0000000000000000000000000000000000000000..357f66c97685924f2449adbf327d518a7d03cb90 GIT binary patch literal 54272 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 literal 0 HcmV?d00001 diff --git a/source/scripts/argon-rpi-eeprom-config-default.py b/source/scripts/argon-rpi-eeprom-config-default.py new file mode 100644 index 0000000..abb46a1 --- /dev/null +++ b/source/scripts/argon-rpi-eeprom-config-default.py @@ -0,0 +1,576 @@ +#!/usr/bin/env python3 + +# Based on /usr/bin/rpi-eeprom-config of bookworm +""" +rpi-eeprom-config +""" + +import argparse +import atexit +import os +import subprocess +import string +import struct +import sys +import tempfile +import time + +VALID_IMAGE_SIZES = [512 * 1024, 2 * 1024 * 1024] + +BOOTCONF_TXT = 'bootconf.txt' +BOOTCONF_SIG = 'bootconf.sig' +PUBKEY_BIN = 'pubkey.bin' + +# Each section starts with a magic number followed by a 32 bit offset to the +# next section (big-endian). +# The number, order and size of the sections depends on the bootloader version +# but the following mask can be used to test for section headers and skip +# unknown data. +# +# The last 4KB of the EEPROM image is reserved for internal use by the +# bootloader and may be overwritten during the update process. +MAGIC = 0x55aaf00f +PAD_MAGIC = 0x55aafeef +MAGIC_MASK = 0xfffff00f +FILE_MAGIC = 0x55aaf11f # id for modifiable files +FILE_HDR_LEN = 20 +FILENAME_LEN = 12 +TEMP_DIR = None + +# Modifiable files are stored in a single 4K erasable sector. +# The max content 4076 bytes because of the file header. +ERASE_ALIGN_SIZE = 4096 +MAX_FILE_SIZE = ERASE_ALIGN_SIZE - FILE_HDR_LEN + +DEBUG = False + +# BEGIN: Argon40 added methods +def argon_rpisupported(): + # bcm2711 = pi4, bcm2712 = pi5 + return rpi5() + +def argon_edit_config(): + # modified/stripped version of edit_config + + config_src = '' + # If there is a pending update then use the configuration from + # that in order to support incremental updates. Otherwise, + # use the current EEPROM configuration. + bootfs = shell_cmd(['rpi-eeprom-update', '-b']).rstrip() + pending = os.path.join(bootfs, 'pieeprom.upd') + if os.path.exists(pending): + config_src = pending + image = BootloaderImage(pending) + current_config = image.get_file(BOOTCONF_TXT).decode('utf-8') + else: + current_config, config_src = read_current_config() + + # Add NVMe boot priority etc if not yet set + foundnewsetting = 0 + addsetting="\nBOOT_UART=1\nWAKE_ON_GPIO=0\nPOWER_OFF_ON_HALT=1\nBOOT_ORDER=0xf416\nPCIE_PROBE=1" + current_config_lines = current_config.splitlines() + new_config = current_config + lineidx = 0 + while lineidx < len(current_config_lines): + current_config_pair = current_config_lines[lineidx].split("=") + newsetting = "" + if current_config_pair[0] == "BOOT_UART": + newsetting = "BOOT_UART=1" + elif current_config_pair[0] == "WAKE_ON_GPIO": + newsetting = "WAKE_ON_GPIO=0" + elif current_config_pair[0] == "POWER_OFF_ON_HALT": + newsetting = "POWER_OFF_ON_HALT=1" + elif current_config_pair[0] == "BOOT_ORDER": + newsetting = "BOOT_ORDER=0xf416" + elif current_config_pair[0] == "PCIE_PROBE": + newsetting = "PCIE_PROBE=1" + + if newsetting != "": + addsetting = addsetting.replace("\n"+newsetting,"",1) + if current_config_lines[lineidx] != newsetting: + foundnewsetting = foundnewsetting + 1 + new_config = new_config.replace(current_config_lines[lineidx], newsetting, 1) + + lineidx = lineidx + 1 + + if addsetting != "": + # Append additional settings after [all] + new_config = new_config.replace("[all]", "[all]"+addsetting, 1) + foundnewsetting = foundnewsetting + 1 + + if foundnewsetting == 0: + # Already configured + print("EEPROM settings up to date") + sys.exit(0) + + # Skipped editor and write new config to temp file + create_tempdir() + tmp_conf = os.path.join(TEMP_DIR, 'boot.conf') + out = open(tmp_conf, 'w') + out.write(new_config) + out.close() + + # Apply updates + + apply_update(tmp_conf, None, config_src) + +# END: Argon40 added methods + + +def debug(s): + if DEBUG: + sys.stderr.write(s + '\n') + + +def rpi4(): + compatible_path = "/sys/firmware/devicetree/base/compatible" + if os.path.exists(compatible_path): + with open(compatible_path, "rb") as f: + compatible = f.read().decode('utf-8') + if "bcm2711" in compatible: + return True + return False + +def rpi5(): + compatible_path = "/sys/firmware/devicetree/base/compatible" + if os.path.exists(compatible_path): + with open(compatible_path, "rb") as f: + compatible = f.read().decode('utf-8') + if "bcm2712" in compatible: + return True + return False + +def exit_handler(): + """ + Delete any temporary files. + """ + if TEMP_DIR is not None and os.path.exists(TEMP_DIR): + tmp_image = os.path.join(TEMP_DIR, 'pieeprom.upd') + if os.path.exists(tmp_image): + os.remove(tmp_image) + tmp_conf = os.path.join(TEMP_DIR, 'boot.conf') + if os.path.exists(tmp_conf): + os.remove(tmp_conf) + os.rmdir(TEMP_DIR) + +def create_tempdir(): + global TEMP_DIR + if TEMP_DIR is None: + TEMP_DIR = tempfile.mkdtemp() + +def pemtobin(infile): + """ + Converts an RSA public key into the format expected by the bootloader. + """ + # Import the package here to make this a weak dependency. + from Cryptodome.PublicKey import RSA + + arr = bytearray() + f = open(infile,'r') + key = RSA.importKey(f.read()) + + if key.size_in_bits() != 2048: + raise Exception("RSA key size must be 2048") + + # Export N and E in little endian format + arr.extend(key.n.to_bytes(256, byteorder='little')) + arr.extend(key.e.to_bytes(8, byteorder='little')) + return arr + +def exit_error(msg): + """ + Trapped a fatal error, output message to stderr and exit with non-zero + return code. + """ + sys.stderr.write("ERROR: %s\n" % msg) + sys.exit(1) + +def shell_cmd(args): + """ + Executes a shell command waits for completion returning STDOUT. If an + error occurs then exit and output the subprocess stdout, stderr messages + for debug. + """ + start = time.time() + arg_str = ' '.join(args) + result = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + while time.time() - start < 5: + if result.poll() is not None: + break + + if result.poll() is None: + exit_error("%s timeout" % arg_str) + + if result.returncode != 0: + exit_error("%s failed: %d\n %s\n %s\n" % + (arg_str, result.returncode, result.stdout.read(), result.stderr.read())) + else: + return result.stdout.read().decode('utf-8') + +def get_latest_eeprom(): + """ + Returns the path of the latest EEPROM image file if it exists. + """ + latest = shell_cmd(['rpi-eeprom-update', '-l']).rstrip() + if not os.path.exists(latest): + exit_error("EEPROM image '%s' not found" % latest) + return latest + +def apply_update(config, eeprom=None, config_src=None): + """ + Applies the config file to the latest available EEPROM image and spawns + rpi-eeprom-update to schedule the update at the next reboot. + """ + if eeprom is not None: + eeprom_image = eeprom + else: + eeprom_image = get_latest_eeprom() + create_tempdir() + + # Replace the contents of bootconf.txt with the contents of the config file + tmp_update = os.path.join(TEMP_DIR, 'pieeprom.upd') + image = BootloaderImage(eeprom_image, tmp_update) + image.update_file(config, BOOTCONF_TXT) + image.write() + + config_str = open(config).read() + if config_src is None: + config_src = '' + sys.stdout.write("Updating bootloader EEPROM\n image: %s\nconfig_src: %s\nconfig: %s\n%s\n%s\n%s\n" % + (eeprom_image, config_src, config, '#' * 80, config_str, '#' * 80)) + + sys.stdout.write("\n*** To cancel this update run 'sudo rpi-eeprom-update -r' ***\n\n") + + # Ignore APT package checksums so that this doesn't fail when used + # with EEPROMs with configs delivered outside of APT. + # The checksums are really just a safety check for automatic updates. + args = ['rpi-eeprom-update', '-d', '-i', '-f', tmp_update] + resp = shell_cmd(args) + sys.stdout.write(resp) + +def edit_config(eeprom=None): + """ + Implements something like 'git commit' for editing EEPROM configs. + """ + # Default to nano if $EDITOR is not defined. + editor = 'nano' + if 'EDITOR' in os.environ: + editor = os.environ['EDITOR'] + + config_src = '' + # If there is a pending update then use the configuration from + # that in order to support incremental updates. Otherwise, + # use the current EEPROM configuration. + bootfs = shell_cmd(['rpi-eeprom-update', '-b']).rstrip() + pending = os.path.join(bootfs, 'pieeprom.upd') + if os.path.exists(pending): + config_src = pending + image = BootloaderImage(pending) + current_config = image.get_file(BOOTCONF_TXT).decode('utf-8') + else: + current_config, config_src = read_current_config() + + create_tempdir() + tmp_conf = os.path.join(TEMP_DIR, 'boot.conf') + out = open(tmp_conf, 'w') + out.write(current_config) + out.close() + cmd = "\'%s\' \'%s\'" % (editor, tmp_conf) + result = os.system(cmd) + if result != 0: + exit_error("Aborting update because \'%s\' exited with code %d." % (cmd, result)) + + new_config = open(tmp_conf, 'r').read() + if len(new_config.splitlines()) < 2: + exit_error("Aborting update because \'%s\' appears to be empty." % tmp_conf) + apply_update(tmp_conf, eeprom, config_src) + +def read_current_config(): + """ + Reads the configuration used by the current bootloader. + """ + fw_base = "/sys/firmware/devicetree/base/" + nvmem_base = "/sys/bus/nvmem/devices/" + + if os.path.exists(fw_base + "/aliases/blconfig"): + with open(fw_base + "/aliases/blconfig", "rb") as f: + nvmem_ofnode_path = fw_base + f.read().decode('utf-8') + for d in os.listdir(nvmem_base): + if os.path.realpath(nvmem_base + d + "/of_node") in os.path.normpath(nvmem_ofnode_path): + return (open(nvmem_base + d + "/nvmem", "rb").read().decode('utf-8'), "blconfig device") + + return (shell_cmd(['vcgencmd', 'bootloader_config']), "vcgencmd bootloader_config") + +class ImageSection: + def __init__(self, magic, offset, length, filename=''): + self.magic = magic + self.offset = offset + self.length = length + self.filename = filename + debug("ImageSection %x offset %d length %d %s" % (magic, offset, length, filename)) + +class BootloaderImage(object): + def __init__(self, filename, output=None): + """ + Instantiates a Bootloader image writer with a source eeprom (filename) + and optionally an output filename. + """ + self._filename = filename + self._sections = [] + self._image_size = 0 + try: + self._bytes = bytearray(open(filename, 'rb').read()) + except IOError as err: + exit_error("Failed to read \'%s\'\n%s\n" % (filename, str(err))) + self._out = None + if output is not None: + self._out = open(output, 'wb') + + self._image_size = len(self._bytes) + if self._image_size not in VALID_IMAGE_SIZES: + exit_error("%s: Expected size %d bytes actual size %d bytes" % + (filename, self._image_size, len(self._bytes))) + self.parse() + + def parse(self): + """ + Builds a table of offsets to the different sections in the EEPROM. + """ + offset = 0 + magic = 0 + while offset < self._image_size: + magic, length = struct.unpack_from('>LL', self._bytes, offset) + if magic == 0x0 or magic == 0xffffffff: + break # EOF + elif (magic & MAGIC_MASK) != MAGIC: + raise Exception('EEPROM is corrupted %x %x %x' % (magic, magic & MAGIC_MASK, MAGIC)) + + filename = '' + if magic == FILE_MAGIC: # Found a file + # Discard trailing null characters used to pad filename + filename = self._bytes[offset + 8: offset + FILE_HDR_LEN].decode('utf-8').replace('\0', '') + debug("section at %d length %d magic %08x %s" % (offset, length, magic, filename)) + self._sections.append(ImageSection(magic, offset, length, filename)) + + offset += 8 + length # length + type + offset = (offset + 7) & ~7 + + def find_file(self, filename): + """ + Returns the offset, length and whether this is the last section in the + EEPROM for a modifiable file within the image. + """ + offset = -1 + length = -1 + is_last = False + + next_offset = self._image_size - ERASE_ALIGN_SIZE # Don't create padding inside the bootloader scratch page + for i in range(0, len(self._sections)): + s = self._sections[i] + if s.magic == FILE_MAGIC and s.filename == filename: + is_last = (i == len(self._sections) - 1) + offset = s.offset + length = s.length + break + + # Find the start of the next non padding section + i += 1 + while i < len(self._sections): + if self._sections[i].magic == PAD_MAGIC: + i += 1 + else: + next_offset = self._sections[i].offset + break + ret = (offset, length, is_last, next_offset) + debug('%s offset %d length %d is-last %d next %d' % (filename, ret[0], ret[1], ret[2], ret[3])) + return ret + + def update(self, src_bytes, dst_filename): + """ + Replaces a modifiable file with specified byte array. + """ + hdr_offset, length, is_last, next_offset = self.find_file(dst_filename) + update_len = len(src_bytes) + FILE_HDR_LEN + + if hdr_offset + update_len > self._image_size - ERASE_ALIGN_SIZE: + raise Exception('No space available - image past EOF.') + + if hdr_offset < 0: + raise Exception('Update target %s not found' % dst_filename) + + if hdr_offset + update_len > next_offset: + raise Exception('Update %d bytes is larger than section size %d' % (update_len, next_offset - hdr_offset)) + + new_len = len(src_bytes) + FILENAME_LEN + 4 + struct.pack_into('>L', self._bytes, hdr_offset + 4, new_len) + struct.pack_into(("%ds" % len(src_bytes)), self._bytes, + hdr_offset + 4 + FILE_HDR_LEN, src_bytes) + + # If the new file is smaller than the old file then set any old + # data which is now unused to all ones (erase value) + pad_start = hdr_offset + 4 + FILE_HDR_LEN + len(src_bytes) + + # Add padding up to 8-byte boundary + while pad_start % 8 != 0: + struct.pack_into('B', self._bytes, pad_start, 0xff) + pad_start += 1 + + # Create a padding section unless the padding size is smaller than the + # size of a section head. Padding is allowed in the last section but + # by convention bootconf.txt is the last section and there's no need to + # pad to the end of the sector. This also ensures that the loopback + # config read/write tests produce identical binaries. + pad_bytes = next_offset - pad_start + if pad_bytes > 8 and not is_last: + pad_bytes -= 8 + struct.pack_into('>i', self._bytes, pad_start, PAD_MAGIC) + pad_start += 4 + struct.pack_into('>i', self._bytes, pad_start, pad_bytes) + pad_start += 4 + + debug("pad %d" % pad_bytes) + pad = 0 + while pad < pad_bytes: + struct.pack_into('B', self._bytes, pad_start + pad, 0xff) + pad = pad + 1 + + def update_key(self, src_pem, dst_filename): + """ + Replaces the specified public key entry with the public key values extracted + from the source PEM file. + """ + pubkey_bytes = pemtobin(src_pem) + self.update(pubkey_bytes, dst_filename) + + def update_file(self, src_filename, dst_filename): + """ + Replaces the contents of dst_filename in the EEPROM with the contents of src_file. + """ + src_bytes = open(src_filename, 'rb').read() + if len(src_bytes) > MAX_FILE_SIZE: + raise Exception("src file %s is too large (%d bytes). The maximum size is %d bytes." + % (src_filename, len(src_bytes), MAX_FILE_SIZE)) + self.update(src_bytes, dst_filename) + + def write(self): + """ + Writes the updated EEPROM image to stdout or the specified output file. + """ + if self._out is not None: + self._out.write(self._bytes) + self._out.close() + else: + if hasattr(sys.stdout, 'buffer'): + sys.stdout.buffer.write(self._bytes) + else: + sys.stdout.write(self._bytes) + + def get_file(self, filename): + hdr_offset, length, is_last, next_offset = self.find_file(filename) + offset = hdr_offset + 4 + FILE_HDR_LEN + file_bytes = self._bytes[offset:offset+length-FILENAME_LEN-4] + return file_bytes + + def extract_files(self): + for i in range(0, len(self._sections)): + s = self._sections[i] + if s.magic == FILE_MAGIC: + file_bytes = self.get_file(s.filename) + open(s.filename, 'wb').write(file_bytes) + + def read(self): + config_bytes = self.get_file('bootconf.txt') + if self._out is not None: + self._out.write(config_bytes) + self._out.close() + else: + if hasattr(sys.stdout, 'buffer'): + sys.stdout.buffer.write(config_bytes) + else: + sys.stdout.write(config_bytes) + +def main(): + """ + Utility for reading and writing the configuration file in the + Raspberry Pi bootloader EEPROM image. + """ + description = """\ +Bootloader EEPROM configuration tool for the Raspberry Pi 4 and Raspberry Pi 5. +Operating modes: + +1. Outputs the current bootloader configuration to STDOUT if no arguments are + specified OR the given output file if --out is specified. + + rpi-eeprom-config [--out boot.conf] + +2. Extracts the configuration file from the given 'eeprom' file and outputs + the result to STDOUT or the output file if --output is specified. + + rpi-eeprom-config pieeprom.bin [--out boot.conf] + +3. Writes a new EEPROM image replacing the configuration file with the contents + of the file specified by --config. + + rpi-eeprom-config --config boot.conf --out newimage.bin pieeprom.bin + + The new image file can be installed via rpi-eeprom-update + rpi-eeprom-update -d -f newimage.bin + +4. Applies a given config file to an EEPROM image and invokes rpi-eeprom-update + to schedule an update of the bootloader when the system is rebooted. + + Since this command launches rpi-eeprom-update to schedule the EEPROM update + it must be run as root. + + sudo rpi-eeprom-config --apply boot.conf [pieeprom.bin] + + If the 'eeprom' argument is not specified then the latest available image + is selected by calling 'rpi-eeprom-update -l'. + +5. The '--edit' parameter behaves the same as '--apply' except that instead of + applying a predefined configuration file a text editor is launched with the + contents of the current EEPROM configuration. + + Since this command launches rpi-eeprom-update to schedule the EEPROM update + it must be run as root. + + The configuration file will be taken from: + * The blconfig reserved memory nvmem device + * The cached bootloader configuration 'vcgencmd bootloader_config' + * The current pending update - typically /boot/pieeprom.upd + + sudo -E rpi-eeprom-config --edit [pieeprom.bin] + + To cancel the pending update run 'sudo rpi-eeprom-update -r' + + The default text editor is nano and may be overridden by setting the 'EDITOR' + environment variable and passing '-E' to 'sudo' to preserve the environment. + +6. Signing the bootloader config file. + Updates an EEPROM binary with a signed config file (created by rpi-eeprom-digest) plus + the corresponding RSA public key. + + Requires Python Cryptodomex libraries and OpenSSL. To install on Raspberry Pi OS run:- + sudo apt install openssl python-pip + sudo python3 -m pip install cryptodomex + + rpi-eeprom-digest -k private.pem -i bootconf.txt -o bootconf.sig + rpi-eeprom-config --config bootconf.txt --digest bootconf.sig --pubkey public.pem --out pieeprom-signed.bin pieeprom.bin + + Currently, the signing process is a separate step so can't be used with the --edit or --apply modes. + + +See 'rpi-eeprom-update -h' for more information about the available EEPROM images. +""" + + if os.getuid() != 0: + exit_error("Please run as root") + elif not argon_rpisupported(): + # Skip + sys.exit(0) + argon_edit_config() + +if __name__ == '__main__': + atexit.register(exit_handler) + main() diff --git a/source/scripts/argonkeyboard.py b/source/scripts/argonkeyboard.py new file mode 100644 index 0000000..8d460cd --- /dev/null +++ b/source/scripts/argonkeyboard.py @@ -0,0 +1,824 @@ +#!/usr/bin/python3 + +# +# This script monitor battery via ic2 and keyboard events. +# +# Additional comments are found in each function below +# +# + + +from evdev import InputDevice, categorize, ecodes, list_devices +from select import select + +import subprocess + +import sys +import os +import time + +from threading import Thread +from queue import Queue + + +UPS_LOGFILE="/dev/shm/upslog.txt" +KEYBOARD_LOCKFILE="/dev/shm/argononeupkeyboardlock.txt" + + +KEYCODE_BRIGHTNESSUP = "KEY_BRIGHTNESSUP" +KEYCODE_BRIGHTNESSDOWN = "KEY_BRIGHTNESSDOWN" +KEYCODE_VOLUMEUP = "KEY_VOLUMEUP" +KEYCODE_VOLUMEDOWN = "KEY_VOLUMEDOWN" +KEYCODE_PAUSE = "KEY_PAUSE" +KEYCODE_MUTE = "KEY_MUTE" + + +################### +# Utilty Functions +################### + +# Debug Logger +def debuglog(typestr, logstr): + return + # try: + # DEBUGFILE="/dev/shm/argononeupkeyboarddebuglog.txt" + # tmpstrpadding = " " + + # with open(DEBUGFILE, "a") as txt_file: + # txt_file.write("["+time.asctime(time.localtime(time.time()))+"] "+typestr.upper()+" "+logstr.strip().replace("\n","\n"+tmpstrpadding)+"\n") + # except: + # pass + +def runcmdlist(key, cmdlist): + try: + cmdresult = subprocess.run(cmdlist, + capture_output=True, + text=True, + check=True + ) + #debuglog(key+"-result-output",str(cmdresult.stdout)) + if cmdresult.stderr: + debuglog(key+"-result-error",str(cmdresult.stderr)) + #debuglog(key+"-result-code",str(cmdresult.returncode)) + + except subprocess.CalledProcessError as e: + debuglog(key+"-error-output",str(e.stdout)) + if e.stderr: + debuglog(key+"-error-error",str(e.stderr)) + debuglog(key+"-error-code",str(e.returncode)) + except FileNotFoundError: + debuglog(key+"-error-filenotfound","Command Not Found") + except Exception as othererr: + try: + debuglog(key+"-error-other", str(othererr)) + except: + debuglog(key+"-error-other", "Other Error") + +def createlockfile(fname): + # try: + # if os.path.isfile(fname): + # return True + # except Exception as checklockerror: + # try: + # debuglog("keyboard-lock-error", str(checklockerror)) + # except: + # debuglog("keyboard-lock-error", "Error Checking Lock File") + # try: + # with open(fname, "w") as txt_file: + # txt_file.write(time.asctime(time.localtime(time.time()))+"\n") + # except Exception as lockerror: + # try: + # debuglog("keyboard-lock-error", str(lockerror)) + # except: + # debuglog("keyboard-lock-error", "Error Creating Lock File") + return False + +def deletelockfile(fname): + # try: + # os.remove(fname) + # except Exception as lockerror: + # try: + # debuglog("keyboard-lock-error", str(lockerror)) + # except: + # debuglog("keyboard-lock-error", "Error Removing Lock File") + return True + + +# System Notifcation +def notifymessage(message, iscritical): + if not isinstance(message, str) or len(message.strip()) == 0: + return + + wftype="notify" + if iscritical: + wftype="critical" + os.system("export SUDO_UID=1000; wfpanelctl "+wftype+" \""+message+"\"") + os.system("export DISPLAY=:0.0; lxpanelctl notify \""+message+"\"") + + +############# +# Battery (copied) +############# + +def battery_loadlogdata(): + # status, version, time, schedule + outobj = {} + try: + fp = open(UPS_LOGFILE, "r") + logdata = fp.read() + alllines = logdata.split("\n") + ctr = 0 + while ctr < len(alllines): + tmpval = alllines[ctr].strip() + curinfo = tmpval.split(":") + if len(curinfo) > 1: + tmpattrib = curinfo[0].lower().split(" ") + # The rest are assumed to be value + outobj[tmpattrib[0]] = tmpval[(len(curinfo[0])+1):].strip() + ctr = ctr + 1 + except Exception as einit: + try: + debuglog("keyboard-battery-error", str(einit)) + except: + debuglog("keyboard-battery-error", "Error getting battery status") + #pass + + return outobj + + +def keyboardevent_getdevicepaths(): + outlist = [] + try: + for path in list_devices(): + try: + tmpdevice = InputDevice(path) + keyeventlist = tmpdevice.capabilities().get(ecodes.EV_KEY, []) + # Keyboard has EV_KEY (key) and EV_REP (autorepeat) + if ecodes.KEY_BRIGHTNESSDOWN in keyeventlist and ecodes.KEY_BRIGHTNESSDOWN in keyeventlist: + outlist.append(path) + #debuglog("keyboard-device-keys", path) + #debuglog("keyboard-device-keys", str(keyeventlist)) + elif ecodes.KEY_F2 in keyeventlist and ecodes.KEY_F3 in keyeventlist: + # Keyboards with FN key sometimes do not include KEY_BRIGHTNESS in declaration + outlist.append(path) + #debuglog("keyboard-device-keys", path) + #debuglog("keyboard-device-keys", str(keyeventlist)) + tmpdevice.close() + except: + pass + except: + pass + return outlist + +def keyboardevent_devicechanged(curlist, newlist): + try: + for curpath in curlist: + if curpath not in newlist: + return True + for newpath in newlist: + if newpath not in curlist: + return True + except: + pass + return False + +def keyboardevent_getbrigthnesstoolid(): + toolid = 0 + try: + output = subprocess.check_output(["ddcutil", "--version"], text=True, stderr=subprocess.DEVNULL) + lines = output.splitlines() + if len(lines) > 0: + tmpline = lines[0].strip() + toolid = int(tmpline.split(" ")[1].split(".")[0]) + except Exception as einit: + try: + debuglog("keyboard-brightness-tool-error", str(einit)) + except: + debuglog("keyboard-brightness-tool-error", "Error getting tool id value") + + debuglog("keyboard-brightness-tool", toolid) + return toolid + +def keyboardevent_getbrigthnessinfo(toolid, defaultlevel=50): + level = defaultlevel + try: + # VCP code x10(Brightness ): current value = 90, max value = 100 + if toolid > 1: + # Disabled dynamic sleep "--disable-dynamic-sleep", "--sleep-multiplier", "0.1" + output = subprocess.check_output(["ddcutil", "--skip-ddc-checks", "--disable-dynamic-sleep", "--sleep-multiplier", "0.1", "getvcp", "10"], text=True, stderr=subprocess.DEVNULL) + else: + output = subprocess.check_output(["ddcutil", "--sleep-multiplier", "0.1", "getvcp", "10"], text=True, stderr=subprocess.DEVNULL) + debuglog("keyboard-brightness-info", output) + level = int(output.split(":")[-1].split(",")[0].split("=")[-1].strip()) + except Exception as einit: + try: + debuglog("keyboard-brightness-error", str(einit)) + except: + debuglog("keyboard-brightness-error", "Error getting base value") + + + return { + "level": level + } + + +def keyboardevent_adjustbrigthness(toolid, baselevel, adjustval=5): + curlevel = baselevel + if adjustval == 0: + return { + "level": baselevel + } + + # Moved reading because ddcutil has delay + # try: + # tmpobj = keyboardevent_getbrigthnessinfo(toolid, curlevel) + # curlevel = tmpobj["level"] + # except Exception: + # pass + + tmpval = max(10, min(100, curlevel + adjustval)) + if tmpval != curlevel: + try: + debuglog("keyboard-brightness", str(curlevel)+"% to "+str(tmpval)+"%") + if toolid > 1: + # Disabled dynamic sleep "--disable-dynamic-sleep", "--sleep-multiplier", "0.1" + runcmdlist("brightness", ["ddcutil", "--skip-ddc-checks", "--disable-dynamic-sleep", "--sleep-multiplier", "0.1", "setvcp", "10", str(tmpval)]) + else: + runcmdlist("brightness", ["ddcutil", "--sleep-multiplier", "0.1", "setvcp", "10", str(tmpval)]) + notifymessage("Brightness: "+str(tmpval)+"%", False) + except Exception as adjusterr: + try: + debuglog("keyboard-brightness-error", str(adjusterr)) + except: + debuglog("keyboard-brightness-error", "Error adjusting value") + return { + "level": curlevel + } + + # DEBUG: Checking + #keyboardevent_getbrigthnessinfo(toolid, tmpval) + return { + "level": tmpval + } + + +def keyboardevent_getvolumesinkid(usedefault=True): + if usedefault == True: + return "@DEFAULT_SINK@" + cursinkid = 0 + try: + output = subprocess.check_output(["wpctl", "status"], text=True, encoding='utf-8', stderr=subprocess.DEVNULL) + + # Find Audio section + tmpline = "" + foundidx = 0 + lines = output.splitlines() + lineidx = 0 + while lineidx < len(lines): + tmpline = lines[lineidx].strip() + if tmpline == "Audio": + foundidx = lineidx + break + lineidx = lineidx + 1 + + if foundidx < 1: + return 0 + + # Find Sinks section + foundidx = 0 + lineidx = lineidx + 1 + while lineidx < len(lines): + if "Sinks:" in lines[lineidx]: + foundidx = lineidx + break + elif len(lines[lineidx]) < 1: + break + lineidx = lineidx + 1 + + if foundidx < 1: + return 0 + + # Get find default id, or first id + lineidx = lineidx + 1 + while lineidx < len(lines): + if "vol:" in lines[lineidx] and "." in lines[lineidx]: + tmpstr = lines[lineidx].split(".")[0] + tmplist = tmpstr.split() + if len(tmplist) > 1: + if tmplist[len(tmplist)-2] == "*": + return int(tmplist[len(tmplist)-1]) + if len(tmplist) > 0 and cursinkid < 1: + cursinkid = int(tmplist[len(tmplist)-1]) + elif len(lines[lineidx]) < 3: + break + lineidx = lineidx + 1 + except Exception as einit: + try: + debuglog("keyboard-volume-error", str(einit)) + except: + debuglog("keyboard-volume-error", "Error getting device ID") + + return cursinkid + + +def keyboardevent_getvolumeinfo(deviceidstr="", defaultlevel=50, defaultmuted=0): + muted = defaultmuted + level = defaultlevel + try: + if deviceidstr == "": + audioidstr = str(keyboardevent_getvolumesinkid()) + if audioidstr == "0": + debuglog("keyboard-volume-error", "Error getting device id") + return { + "level": defaultmuted, + "muted": defaultlevel + } + + deviceidstr = audioidstr + + output = subprocess.check_output(["wpctl", "get-volume", deviceidstr], text=True, stderr=subprocess.DEVNULL) + debuglog("keyboard-volume-info", output) + + muted = 0 + level = 0 + # Parse output, examples + # Volume: 0.65 + # Volume: 0.55 [MUTED] + outlist = output.split() + if len(outlist) > 0: + # Get last element + tmpstr = outlist[len(outlist)-1] + # Check if muted + if "MUTE" in tmpstr: + muted = 1 + if len(outlist) > 1: + tmpstr = outlist[len(outlist)-2] + if tmpstr.endswith("%"): + # Level 100% to 0% + level = int(float(tmpstr[:-1])) + elif tmpstr.replace('.', '').isdigit(): + # Level 1.00 to 0.00 + level = int(float(tmpstr) * 100.0) + except Exception as einit: + try: + debuglog("keyboard-volume-error", str(einit)) + except: + debuglog("keyboard-volume-error", "Error getting base value") + return { + "level": defaultmuted, + "muted": defaultlevel + } + + #debuglog("keyboard-volume-get", str(level)+"% Mute:"+str(muted)) + + return { + "level": level, + "muted": muted + } + + +def keyboardevent_adjustvolume(baselevel, basemuted, adjustval=5): + curlevel = baselevel + curmuted = basemuted + needsnotification = False + + deviceidstr = str(keyboardevent_getvolumesinkid()) + if deviceidstr == "0": + debuglog("keyboard-volume-error", "Error getting device id") + return { + "level": baselevel, + "muted": basemuted + } + + # try: + # tmpobj = keyboardevent_getvolumeinfo(deviceidstr, curlevel, curmuted) + # curlevel = tmpobj["level"] + # curmuted = tmpobj["muted"] + # except Exception: + # pass + + tmpmuted = curmuted + if adjustval == 0: + # Toggle Mute + if curmuted == 0: + tmpmuted = 1 + else: + tmpmuted = 0 + + tmpval = max(10, min(100, curlevel + adjustval)) + if tmpval != curlevel: + try: + debuglog("keyboard-volume", str(curlevel)+"% to "+str(tmpval)+"%") + runcmdlist("volume", ["wpctl", "set-volume", deviceidstr, f"{tmpval}%"]) + needsnotification = True + tmpmuted = 0 + except Exception as adjusterr: + try: + debuglog("keyboard-volume-error", str(adjusterr)) + except: + debuglog("keyboard-volume-error", "Error adjusting value") + return { + "level": curlevel, + "muted": curmuted + } + elif adjustval != 0: + # To unmute even if no volume level change + tmpmuted = 0 + + if tmpmuted != curmuted: + try: + debuglog("keyboard-mute", str(tmpmuted)) + runcmdlist("mute", ["wpctl", "set-mute", deviceidstr, str(tmpmuted)]) + needsnotification = True + except Exception as adjusterr: + try: + debuglog("keyboard-mute-error", str(adjusterr)) + except: + debuglog("keyboard-mute-error", "Error adjusting value") + return { + "level": tmpval, + "muted": curmuted + } + #if tmpmuted == 1: + # notifymessage("Volume: Muted", False) + #else: + # notifymessage("Volume: "+str(tmpval)+"%", False) + + # DEBUG: Checking + #keyboardevent_getvolumeinfo(deviceidstr, tmpval, tmpmuted) + + return { + "level": tmpval, + "muted": tmpmuted + } + +def keyboard_getlayoutfieldvalue(tmpval): + debuglog("keyboard-layout-lang", tmpval) + if "us" in tmpval: + debuglog("keyboard-layout-langout", "us") + return "us" + debuglog("keyboard-layout-langout", "gb") + return "gb" # uk, gb, etc + #return tmpval + + +def keyboard_getdevicefw(kbdevice): + # info: vendor 0x6080=24704, product 0x8062=32866 + try: + if kbdevice.info.vendor == 24704 and kbdevice.info.product == 32866: + # Special HID + return "314" + except Exception as infoerr: + pass + + return "" + + +def keyboardevemt_keyhandler(readq): + + ADJUSTTYPE_NONE=0 + ADJUSTTYPE_BRIGHTNESS=1 + ADJUSTTYPE_VOLUME=2 + ADJUSTTYPE_MUTE=3 + ADJUSTTYPE_BATTERYINFO=4 + + DATAREFRESHINTERVALSEC = 10 + + PRESSWAITINTERVALSEC = 0.5 + FIRSTHOLDINTERVALSEC = 0.5 + HOLDWAITINTERVALSEC = 0.5 + + + # Get current levels + volumetime = time.time() + curvolumemuted = 0 + curvolume = 50 + + brightnesstime = volumetime + curbrightness = 50 + brightnesstoolid = 0 + + try: + brightnesstoolid = keyboardevent_getbrigthnesstoolid() + except Exception: + brightnesstoolid = 0 + pass + + try: + tmpobj = keyboardevent_getbrigthnessinfo(brightnesstoolid) + curbrightness = tmpobj["level"] + except Exception: + pass + + try: + tmpobj = keyboardevent_getvolumeinfo() + curvolumemuted = tmpobj["muted"] + curvolume = tmpobj["level"] + except Exception: + pass + + while True: + try: + tmpkeymode = 0 + tmpkeycode = "" + adjustval = 0 + adjusttype = ADJUSTTYPE_NONE + + tmpcode = readq.get() # Blocking + try: + codeelements = tmpcode.split("+") + if len(codeelements) == 2: + if codeelements[0] == "PRESS": + tmpkeymode = 1 + else: + tmpkeymode = 2 + tmpkeycode = codeelements[1] + elif tmpcode == "EXIT": + readq.task_done() + return + + except Exception: + tmpkeycode = "" + tmpkeymode = 0 + pass + tmptime = time.time() + if tmpkeycode in [KEYCODE_BRIGHTNESSDOWN, KEYCODE_BRIGHTNESSUP]: + if tmpkeymode == 1 and tmptime - brightnesstime > DATAREFRESHINTERVALSEC: + # Do not update value during hold + try: + tmpobj = keyboardevent_getbrigthnessinfo(brightnesstoolid) + curbrightness = tmpobj["level"] + except Exception: + pass + + adjusttype = ADJUSTTYPE_BRIGHTNESS + if tmpkeycode == KEYCODE_BRIGHTNESSDOWN: + adjustval = -5*tmpkeymode + else: + adjustval = 5*tmpkeymode + brightnesstime = tmptime + elif tmpkeycode in [KEYCODE_MUTE, KEYCODE_VOLUMEDOWN, KEYCODE_VOLUMEUP]: + if tmpkeymode == 1 and tmptime - volumetime > DATAREFRESHINTERVALSEC and tmpkeymode == 1: + # Do not update value during hold + try: + tmpobj = keyboardevent_getvolumeinfo() + curvolumemuted = tmpobj["muted"] + curvolume = tmpobj["level"] + except Exception: + pass + + if tmpkeycode == KEYCODE_MUTE: + adjusttype = ADJUSTTYPE_MUTE + adjustval = 0 + else: + adjusttype = ADJUSTTYPE_VOLUME + if tmpkeycode == KEYCODE_VOLUMEDOWN: + adjustval = -5*tmpkeymode + else: + adjustval = 5*tmpkeymode + volumetime = tmptime + + elif tmpkeycode == KEYCODE_PAUSE: + adjusttype = ADJUSTTYPE_BATTERYINFO + else: + readq.task_done() + continue + + try: + tmplockfilea = KEYBOARD_LOCKFILE+".a" + if createlockfile(tmplockfilea) == False: + # Debug ONLY + # if tmpkeymode == 1: + # debuglog("keyboard-event", "Press Key Code: "+str(tmpkeycode)) + # else: + # debuglog("keyboard-event", "Hold Key Code: "+str(tmpkeycode)) + + if adjusttype == ADJUSTTYPE_BRIGHTNESS: + try: + tmpobj = keyboardevent_adjustbrigthness(brightnesstoolid, curbrightness, adjustval) + curbrightness = tmpobj["level"] + except Exception as brightnesserr: + try: + debuglog("keyboard-brightnessother-error", str(brightnesserr)) + except: + debuglog("keyboard-brightnessother-error", "Error adjusting value") + pass + elif adjusttype == ADJUSTTYPE_VOLUME or adjusttype == ADJUSTTYPE_MUTE: + try: + tmpobj = keyboardevent_adjustvolume(curvolume, curvolumemuted, adjustval) + curvolumemuted = tmpobj["muted"] + curvolume = tmpobj["level"] + except Exception as volumeerr: + try: + debuglog("keyboard-volumeother-error", str(volumeerr)) + except: + debuglog("keyboard-volumeother-error", "Error adjusting value") + pass + elif adjusttype == ADJUSTTYPE_BATTERYINFO: + outobj = battery_loadlogdata() + try: + notifymessage(outobj["power"], False) + except: + pass + deletelockfile(tmplockfilea) + + + except Exception as keyhandlererr: + try: + debuglog("keyboard-handlererror", str(keyhandleerr)) + except: + debuglog("keyboard-handlererror", "Error") + + readq.task_done() + + except Exception as mainerr: + time.sleep(10) + # While True + + +def keyboardevent_monitor(writeq): + + READTIMEOUTSECS = 1.0 + + FIRSTHOLDINTERVALSEC = 0.5 + HOLDWAITINTERVALSEC = 0.5 + + while True: + try: + keypresstimestamp = {} + keyholdtimestamp = {} + # Get Devices + devicelist = [] + devicefdlist = [] + devicepathlist = keyboardevent_getdevicepaths() + devicefwlist = [] + + deviceidx = 0 + while deviceidx < len(devicepathlist): + try: + tmpdevice = InputDevice(devicepathlist[deviceidx]) + devicelist.append(tmpdevice) + devicefdlist.append(tmpdevice.fd) + devicefwlist.append(keyboard_getdevicefw(tmpdevice)) + #debuglog("keyboard-device-info", devicepathlist[deviceidx]) + #debuglog("keyboard-device-info", str(tmpdevice.info)) + except Exception as deverr: + try: + debuglog("keyboard-deviceerror", str(deverr)+ " "+ devicepathlist[deviceidx]) + except: + debuglog("keyboard-deviceerror", "Error "+devicepathlist[deviceidx]) + deviceidx = deviceidx + 1 + + try: + debuglog("keyboard-update", str(len(devicefdlist))+" Devices") + while len(devicefdlist) > 0: + # Exception when one of the devices gets removed + # Wait for events on any registered device + r, w, x = select(devicefdlist, [], [], READTIMEOUTSECS) + for fd in r: + found = False + curdevicefw = "" + deviceidx = 0 + while deviceidx < len(devicefdlist): + if devicefdlist[deviceidx] == fd: + curdevicefw = devicefwlist[deviceidx] + found = True + break + deviceidx = deviceidx + 1 + if found: + for event in devicelist[deviceidx].read(): + try: + # Process the event + #print("Device: "+devicelist[deviceidx].path+", Event: ", event) + #debuglog("keyboard-event", "Device: "+devicelist[deviceidx].path+", Event: "+str(event)) + if event.type == ecodes.EV_KEY: + key_event = categorize(event) + keycodelist = [] + # 2 hold, 0 release, 1 press + if event.value == 2 or event.value == 1: + #debuglog("keyboard-event", "Mode:"+str(event.value)+" Key Code: "+str(key_event.keycode)) + + if isinstance(key_event.keycode, str): + keycodelist = [key_event.keycode] + else: + keycodelist = key_event.keycode + else: + continue + + keycodelistidx = 0 + while keycodelistidx < len(keycodelist): + tmpkeycode = keycodelist[keycodelistidx] + if curdevicefw == "314": + # Remap printscreen event as pause and vice versa for special handling + if tmpkeycode == "KEY_PRINTSCREEN": + tmpkeycode = KEYCODE_PAUSE + elif tmpkeycode == "KEY_SYSRQ": + # This gets fired for some devices + tmpkeycode = KEYCODE_PAUSE + elif tmpkeycode == KEYCODE_PAUSE: + # Some other key so it will not fire + tmpkeycode = "KEY_PRINTSCREEN" + #debuglog("keyboard-event", "FW:" + curdevicefw+ " Key Code: "+tmpkeycode + " Press:"+keycodelist[keycodelistidx]) + + + keycodelistidx = keycodelistidx + 1 + # if tmpkeycode not in [KEYCODE_BRIGHTNESSDOWN, KEYCODE_BRIGHTNESSUP, KEYCODE_VOLUMEDOWN, KEYCODE_VOLUMEUP]: + # if event.value == 2: + # # Skip hold for unhandled keys + # continue + # elif tmpkeycode not in [KEYCODE_PAUSE, KEYCODE_MUTE]: + # # Skip press for unhandled keys + # continue + if tmpkeycode not in [KEYCODE_BRIGHTNESSDOWN, KEYCODE_BRIGHTNESSUP]: + if event.value == 2: + # Skip hold for unhandled keys + continue + elif tmpkeycode not in [KEYCODE_PAUSE]: + # Skip press for unhandled keys + continue + + tmptime = time.time() + finalmode = event.value + if event.value == 2: + # Hold needs checking + if tmpkeycode in keypresstimestamp: + # Guard time before first for hold + if (tmptime - keypresstimestamp[tmpkeycode]) >= FIRSTHOLDINTERVALSEC: + # Guard time for hold + if tmpkeycode in keyholdtimestamp: + if (tmptime - keyholdtimestamp[tmpkeycode]) < HOLDWAITINTERVALSEC: + #debuglog("keyboard-event", "Hold Key Code: "+str(tmpkeycode)+" - Skip") + continue + else: + #debuglog("keyboard-event", "Hold Key Code: "+str(tmpkeycode)+" - Skip") + continue + else: + # Should not happen, but treat as if first press + finalmode = 1 + + #debuglog("keyboard-event", "Mode:"+str(event.value) + " Final:"+str(finalmode)+" " +str(tmpkeycode)) + + if finalmode == 1: + keypresstimestamp[tmpkeycode] = tmptime + writeq.put("PRESS+"+tmpkeycode) + else: + keyholdtimestamp[tmpkeycode] = tmptime + writeq.put("HOLD+"+tmpkeycode) + + except Exception as keyhandleerr: + try: + debuglog("keyboard-keyerror", str(keyhandleerr)) + except: + debuglog("keyboard-keyerror", "Error") + + newpathlist = keyboardevent_getdevicepaths() + if keyboardevent_devicechanged(devicepathlist, newpathlist): + debuglog("keyboard-update", "Device list changed") + break + + except Exception as e: + try: + debuglog("keyboard-mainerror", str(e)) + except: + debuglog("keyboard-mainerror", "Error") + + # Close devices + while len(devicelist) > 0: + tmpdevice = devicelist.pop(0) + try: + tmpdevice.close() + except: + pass + + except Exception as mainerr: + time.sleep(10) + # While True + try: + writeq.put("EXIT") + except Exception: + pass + + +if len(sys.argv) > 1: + cmd = sys.argv[1].upper() + if cmd == "SERVICE": + if createlockfile(KEYBOARD_LOCKFILE) == True: + debuglog("keyboard-service", "Already running") + else: + try: + debuglog("keyboard-service", "Service Starting") + ipcq = Queue() + t1 = Thread(target = keyboardevemt_keyhandler, args =(ipcq, )) + t2 = Thread(target = keyboardevent_monitor, args =(ipcq, )) + t1.start() + t2.start() + + ipcq.join() + + except Exception as einit: + try: + debuglog("keyboard-service-error", str(einit)) + except: + debuglog("keyboard-service-error", "Error") + debuglog("keyboard-service", "Service Stopped") + deletelockfile(KEYBOARD_LOCKFILE) diff --git a/source/scripts/argonone-irdecoder-libgpiod.py b/source/scripts/argonone-irdecoder-libgpiod.py new file mode 100644 index 0000000..f9bc41b --- /dev/null +++ b/source/scripts/argonone-irdecoder-libgpiod.py @@ -0,0 +1,525 @@ +#!/usr/bin/python3 + +# Standard Headers +import sys +import smbus + +# For GPIO +import gpiod +from datetime import datetime + +import os +import time + +# Check if Lirc Lib is installed +haslirclib = os.path.isfile("/usr/bin/mode2") +if haslirclib == True: + from multiprocessing import Process + +######################### +# Use GPIO + +def getGPIOPulseData(): + # Counter + ctr = 0 + + # Pin Assignments + LINE_IRRECEIVER=23 + + try: + try: + # Pi5 mapping + chip = gpiod.Chip('4') + except Exception as gpioerr: + # Old mapping + chip = gpiod.Chip('0') + lineobj = chip.get_line(LINE_IRRECEIVER) + lineobj.request(consumer="argon", type=gpiod.LINE_REQ_EV_BOTH_EDGES) + except Exception as e: + # GPIO Error + return [(-2, -2)] + + # Start reading + value = lineobj.get_value() + + # mark time + startTime = datetime.now() + pulseTime = startTime + + # Pulse Data + pulsedata = [] + + aborted = False + while aborted == False: + # Wait for transition + try: + while True: + hasevent = lineobj.event_wait(10) + if hasevent: + # Event data needs to be read + eventdata = lineobj.event_read() + break + except Exception as e: + # GPIO Error + lineobj.release() + chip.close() + return [(-2, -2)] + + # high/low Length + now = datetime.now() + pulseLength = now - pulseTime + pulseTime = now + + # Update value (changed triggered), this also inverts value before saving + if value: + value = 0 + else: + value = 1 + + if pulseLength.microseconds > PULSETAIL_MAXMICROS_NEC and ctr == 0: + continue + + pulsedata.append((value, pulseLength.microseconds)) + + ctr = ctr + 1 + if pulseLength.microseconds > PULSETAIL_MAXMICROS_NEC: + break + elif ctr > PULSEDATA_MAXCOUNT: + break + + lineobj.release() + chip.close() + + # Data is most likely incomplete + if aborted == True: + return [] + elif ctr >= PULSEDATA_MAXCOUNT: + print (" * Unable to decode. Please try again *") + return [] + return pulsedata + + +######################### +# Use LIRC +def lircMode2Task(irlogfile): + os.system("mode2 > "+irlogfile+" 2>&1") + +def startLIRCMode2Logging(irlogfile): + # create a new process + loggerprocess = Process(target=lircMode2Task,args=(irlogfile,)) + loggerprocess.start() + # mode2 will start new process, terminate current + time.sleep(0.1) + loggerprocess.kill() + return True + +def endLIRCMode2Logging(irlogfile): + tmplogfile = irlogfile+".tmp" + os.system("ps | grep ode2 > "+tmplogfile+"") + + if os.path.exists(tmplogfile) == True: + ctr = 0 + fp = open(tmplogfile, "r") + for curline in fp: + if len(curline) > 0: + rowdata = curline.split(" ") + pid = "" + processname = "" + colidx = 0 + while colidx < len(rowdata): + if len(rowdata[colidx]) > 0: + if pid == "": + pid = rowdata[colidx] + else: + processname = rowdata[colidx] + + colidx = colidx + 1 + if processname=="mode2\n": + os.system("kill -9 "+pid) + fp.close() + os.remove(tmplogfile) + return True + +def getLIRCPulseData(): + if haslirclib == False: + print (" * LIRC Module not found, please reboot and try again *") + return [] + + irlogfile = "/dev/shm/lircdecoder.log" + + loggerresult = startLIRCMode2Logging(irlogfile) + if loggerresult == False: + return [(-1, -1)] + + # Wait for log file + logsize = 0 + while logsize == 0: + if os.path.exists(irlogfile) == True: + logsize = os.path.getsize(irlogfile) + if logsize == 0: + time.sleep(0.1) + + # Wait for data to start + newlogsize = logsize + while logsize == newlogsize: + time.sleep(0.1) + newlogsize = os.path.getsize(irlogfile) + + print(" Thank you") + + # Wait for data to stop + while logsize != newlogsize: + logsize = newlogsize + time.sleep(0.1) + newlogsize = os.path.getsize(irlogfile) + + # Finalize File + loggerresult = endLIRCMode2Logging(irlogfile) + if loggerresult == False: + return [(-1, -1)] + + # Decode logfile into Pulse Data + pulsedata = [] + + terminated = False + if os.path.exists(irlogfile) == True: + ctr = 0 + fp = open(irlogfile, "r") + for curline in fp: + if len(curline) > 0: + rowdata = curline.split(" ") + if len(rowdata) == 2: + duration = int(rowdata[1]) + value = 0 + if rowdata[0] == "pulse": + value = 1 + ctr = ctr + 1 + if value == 1 or ctr > 1: + if len(pulsedata) > 0 and duration > PULSELEADER_MINMICROS_NEC: + terminated = True + break + else: + pulsedata.append((value, duration)) + fp.close() + os.remove(irlogfile) + + # Check if terminating pulse detected + if terminated == False: + print (" * Unable to read signal. Please try again *") + return [] + return pulsedata + + +######################### +# Common +irconffile = "/etc/lirc/lircd.conf.d/argon.lircd.conf" + +# I2C +address = 0x1a # I2C Address +addressregister = 0xaa # I2C Address Register + +# Constants +PULSETIMEOUTMS = 1000 +VERIFYTARGET = 3 +PULSEDATA_MAXCOUNT = 200 # Fail safe + +# NEC Protocol Constants +PULSEBIT_MAXMICROS_NEC = 2500 +PULSEBIT_ZEROMICROS_NEC = 1000 + +PULSELEADER_MINMICROS_NEC = 8000 +PULSELEADER_MAXMICROS_NEC = 10000 +PULSETAIL_MAXMICROS_NEC = 12000 + +# Flags +FLAGV1ONLY = False + +try: + if os.path.isfile("/etc/argon/flag_v1"): + FLAGV1ONLY = True +except Exception: + FLAGV1ONLY = False + + +# Standard Methods +def getbytestring(pulsedata): + outstring = "" + for curbyte in pulsedata: + tmpstr = hex(curbyte)[2:] + while len(tmpstr) < 2: + tmpstr = "0" + tmpstr + outstring = outstring+tmpstr + return outstring + +def displaybyte(pulsedata): + print (getbytestring(pulsedata)) + + +def pulse2byteNEC(pulsedata): + outdata = [] + bitdata = 1 + curbyte = 0 + bitcount = 0 + for (mode, duration) in pulsedata: + if mode == 1: + continue + elif duration > PULSEBIT_MAXMICROS_NEC: + continue + elif duration > PULSEBIT_ZEROMICROS_NEC: + curbyte = curbyte*2 + 1 + else: + curbyte = curbyte*2 + + bitcount = bitcount + 1 + if bitcount == 8: + outdata.append(curbyte) + curbyte = 0 + bitcount = 0 + # Shouldn't happen, but just in case + if bitcount > 0: + outdata.append(curbyte) + + return outdata + + +def bytecompare(a, b): + idx = 0 + maxidx = len(a) + if maxidx != len(b): + return 1 + while idx < maxidx: + if a[idx] != b[idx]: + return 1 + idx = idx + 1 + return 0 + + +# Main Flow +mode = "custom" +if len(sys.argv) > 1: + mode = sys.argv[1] + +powerdata = [] +buttonlist = ['POWER', 'UP', 'DOWN', 'LEFT', 'RIGHT', + 'VOLUMEUP', 'VOLUMEDOWN', 'OK', 'HOME', 'MENU' + 'BACK'] + +ircodelist = ['00ff39c6', '00ff53ac', '00ff4bb4', '00ff9966', '00ff837c', + '00ff01fe', '00ff817e', '00ff738c', '00ffd32c', '00ffb946', + '00ff09f6'] + +buttonidx = 0 + +if mode == "power": + buttonlist = ['POWER'] + ircodelist = [''] +elif mode == "resetpower": + # Just Set the power so it won't create/update the conf file + buttonlist = ['POWER'] + mode = "default" +elif mode == "custom": + buttonlist = ['POWER', 'UP', 'DOWN', 'LEFT', 'RIGHT', + 'VOLUMEUP', 'VOLUMEDOWN', 'OK', 'HOME', 'MENU' + 'BACK'] + ircodelist = ['', '', '', '', '', + '', '', '', '', '', + ''] + #buttonlist = ['POWER', 'VOLUMEUP', 'VOLUMEDOWN'] + #ircodelist = ['', '', ''] + +if mode == "default": + # To skip the decoding loop + buttonidx = len(buttonlist) + # Set MCU IR code + powerdata = [0x00, 0xff, 0x39, 0xc6] +else: + print ("************************************************") + print ("* WARNING: Current buttons are still active. *") + print ("* Please temporarily assign to a *") + print ("* different button if you plan to *") + print ("* reuse buttons. *") + print ("* e.g. Power Button triggers shutdown *") + print ("* *") + print ("* PROCEED AT YOUR OWN RISK *") + print ("* (Press CTRL+C to abort at any time) *") + print ("************************************************") + +readaborted = False +# decoding loop +while buttonidx < len(buttonlist): + print ("Press your button for "+buttonlist[buttonidx]+" (CTRL+C to abort)") + irprotocol = "" + outdata = [] + verifycount = 0 + readongoing = True + + # Handles NEC protocol Only + while readongoing == True: + # Try GPIO-based reading, if it fails, fallback to LIRC + pulsedata = getGPIOPulseData() + if len(pulsedata) == 1: + if pulsedata[0][0] == -2: + pulsedata = getLIRCPulseData() + + # Aborted + if len(pulsedata) == 1: + if pulsedata[0][0] == -1: + readongoing = False + readaborted = True + buttonidx = len(buttonlist) + break + # Ignore repeat code (NEC) + if len(pulsedata) <= 4: + continue + + # Get leading signal + (mode, duration) = pulsedata[0] + + # Decode IR Protocols + # https://www.sbprojects.net/knowledge/ir/index.php + + if duration >= PULSELEADER_MINMICROS_NEC and duration <= PULSELEADER_MAXMICROS_NEC: + irprotocol = "NEC" + # NEC has 9ms head, +/- 1ms + curdata = pulse2byteNEC(pulsedata) + if len(curdata) > 0: + if verifycount > 0: + if bytecompare(outdata, curdata) == 0: + verifycount = verifycount + 1 + else: + verifycount = 0 + else: + outdata = curdata + verifycount = 1 + + if verifycount >= VERIFYTARGET: + readongoing = False + print ("") + elif verifycount == 0: + print (" * IR code mismatch, please try again *") + elif VERIFYTARGET - verifycount > 1: + print (" Press the button "+ str(VERIFYTARGET - verifycount)+ " more times") + else: + print (" Press the button 1 more time") + else: + print (" * Decoding error. Please try again *") + else: + print (" * Unrecognized signal. Please try again *") + #curdata = pulse2byteLSB(pulsedata) + #displaybyte(curdata) + + # Check for duplicates + newircode = getbytestring(outdata) + if verifycount > 0: + checkidx = 0 + while checkidx < buttonidx and checkidx < len(buttonlist): + if ircodelist[checkidx] == newircode: + print (" Button already assigned. Please try again") + verifycount = 0 + break + checkidx = checkidx + 1 + + # Store code, and power button code if applicable + if verifycount > 0: + if buttonidx == 0: + powerdata = outdata + if buttonidx < len(buttonlist): + # Abort will cause out of bounds + ircodelist[buttonidx] = newircode + #print (buttonlist[buttonidx]+": "+ newircode) + buttonidx = buttonidx + 1 + +if len(powerdata) > 0 and readaborted == False: + # Send to device if completed or reset mode + #print("Writing " + getbytestring(powerdata)) + print("Updating Device...") + try: + bus=smbus.SMBus(1) + except Exception: + try: + # Older version + bus=smbus.SMBus(0) + except Exception: + bus=None + + if bus is None: + print("Device Update Failed: Unable to detect i2c") + else: + # Check for Argon Control Register Support + checkircodewrite = False + argoncyclereg = 0x80 + if FLAGV1ONLY == False: + oldval = bus.read_byte_data(address, argoncyclereg) + newval = oldval + 1 + if newval >= 100: + newval = 98 + bus.write_byte_data(address,argoncyclereg, newval) + time.sleep(1) + newval = bus.read_byte_data(address, argoncyclereg) + + if newval != oldval: + addressregister = 0x82 + checkircodewrite = True + bus.write_byte_data(address,argoncyclereg, oldval) + + bus.write_i2c_block_data(address, addressregister, powerdata) + + + if checkircodewrite == True: + # Check if data was written for devices that support it + print("Verifying ...") + time.sleep(2) + checkircodedata = bus.read_i2c_block_data(address, addressregister, 4) + checkircodecounter = 0 + while checkircodecounter < 4: + # Reuse readaborted flag as indicator if IR code was successfully updated + if checkircodedata[checkircodecounter] != powerdata[checkircodecounter]: + readaborted = True + checkircodecounter = checkircodecounter + 1 + if readaborted == False: + print("Device Update Successful") + else: + print("Verification Failed") + bus.close() + + # Update IR Conf if there are other button + if buttonidx > 1 and readaborted == False: + print("Updating Remote Control Codes...") + fp = open(irconffile, "w") + + # Standard NEC conf header + fp.write("#\n") + fp.write("# Based on NEC templates at http://lirc.sourceforge.net/remotes/nec/\n") + fp.write("# Configured codes based on data gathered\n") + fp.write("#\n") + fp.write("\n") + fp.write("begin remote\n") + fp.write(" name argon\n") + fp.write(" bits 32\n") + fp.write(" flags SPACE_ENC\n") + fp.write(" eps 20\n") + fp.write(" aeps 200\n") + fp.write("\n") + fp.write(" header 8800 4400\n") + fp.write(" one 550 1650\n") + fp.write(" zero 550 550\n") + fp.write(" ptrail 550\n") + fp.write(" repeat 8800 2200\n") + fp.write(" gap 38500\n") + fp.write(" toggle_bit 0\n") + fp.write("\n") + fp.write(" frequency 38000\n") + fp.write("\n") + fp.write(" begin codes\n") + + # Write Key Codes + buttonidx = 1 + while buttonidx < len(buttonlist): + fp.write(" KEY_"+buttonlist[buttonidx]+" 0x"+ircodelist[buttonidx]+"\n") + buttonidx = buttonidx + 1 + fp.write(" end codes\n") + fp.write("end remote\n") + fp.close() + + + diff --git a/source/scripts/argonone-oledconfig.sh b/source/scripts/argonone-oledconfig.sh new file mode 100644 index 0000000..6d4e622 --- /dev/null +++ b/source/scripts/argonone-oledconfig.sh @@ -0,0 +1,294 @@ +#!/bin/bash + +oledconfigfile=/etc/argoneonoled.conf + +get_number () { + read curnumber + if [ -z "$curnumber" ] + then + echo "-2" + return + elif [[ $curnumber =~ ^[+-]?[0-9]+$ ]] + then + if [ $curnumber -lt 0 ] + then + echo "-1" + return + elif [ $curnumber -gt 100 ] + then + echo "-1" + return + fi + echo $curnumber + return + fi + echo "-1" + return +} + +get_pagename() { + if [ "$1" == "clock" ] + then + pagename="Current Date/Time" + elif [ "$1" == "cpu" ] + then + pagename="CPU Utilization" + elif [ "$1" == "storage" ] + then + pagename="Storage Utilization" + elif [ "$1" == "ram" ] + then + pagename="Available RAM" + elif [ "$1" == "temp" ] + then + pagename="CPU Temperature" + elif [ "$1" == "ip" ] + then + pagename="IP Address" + elif [ "$1" == "logo1v5" ] + then + pagename="Logo:One v5" + else + pagename="Invalid" + fi +} + +configure_pagelist () { + pagemasterlist="logo1v5 clock cpu storage ram temp ip" + newscreenlist="$1" + pageloopflag=1 + while [ $pageloopflag -eq 1 ] + do + echo "--------------------------------" + echo " OLED Pages " + echo "--------------------------------" + i=1 + for curpage in $newscreenlist + do + get_pagename $curpage + echo " $i. Remove $pagename" + i=$((i+1)) + done + if [ $i -eq 1 ] + then + echo " No page configured" + fi + echo + echo " $i. Add Page" + echo + echo " 0. Done" + echo -n "Enter Number (0-$i):" + + cmdmode=$( get_number ) + if [ $cmdmode -eq 0 ] + then + pageloopflag=0 + elif [[ $cmdmode -eq $i ]] + then + + echo "--------------------------------" + echo " Choose Page to Add" + echo "--------------------------------" + echo + i=1 + for curpage in $pagemasterlist + do + get_pagename $curpage + echo " $i. $pagename" + i=$((i+1)) + done + + echo + echo " 0. Cancel" + echo -n "Enter Number (0-$i):" + pagenum=$( get_number ) + if [[ $pagenum -ge 1 && $pagenum -le $i ]] + then + i=1 + for curpage in $pagemasterlist + do + if [ $i -eq $pagenum ] + then + if [ "$newscreenlist" == "" ] + then + newscreenlist="$curpage" + else + newscreenlist="$newscreenlist $curpage" + fi + fi + i=$((i+1)) + done + fi + elif [[ $cmdmode -ge 1 && $cmdmode -lt $i ]] + then + tmpscreenlist="" + i=1 + for curpage in $newscreenlist + do + if [ ! $i -eq $cmdmode ] + then + tmpscreenlist="$tmpscreenlist $curpage" + fi + i=$((i+1)) + done + if [ "$tmpscreenlist" == "" ] + then + newscreenlist="$tmpscreenlist" + else + # Remove leading space + newscreenlist="${tmpscreenlist:1}" + fi + fi + done +} + +saveconfig () { + echo "#" > $oledconfigfile + echo "# Argon OLED Configuration" >> $oledconfigfile + echo "#" >> $oledconfigfile + echo "enabled=$1" >> $oledconfigfile + echo "switchduration=$2" >> $oledconfigfile + echo "screensaver=$3" >> $oledconfigfile + echo "screenlist=\"$4\"" >> $oledconfigfile +} + +updateconfig=1 +oledloopflag=1 +while [ $oledloopflag -eq 1 ] +do + if [ $updateconfig -eq 1 ] + then + . $oledconfigfile + fi + + updateconfig=0 + if [ -z "$enabled" ] + then + enabled="Y" + updateconfig=1 + fi + + if [ -z "$screenlist" ] + then + screenlist="ip cpu ram" + updateconfig=1 + fi + + if [ -z "$screensaver" ] + then + screensaver=120 + updateconfig=1 + fi + + if [ -z "$switchduration" ] + then + switchduration=0 + updateconfig=1 + fi + + # Write default values to config file, daemon already uses default so no need to restart service + if [ $updateconfig -eq 1 ] + then + saveconfig $enabled $switchduration $screensaver "$screenlist" + updateconfig=0 + fi + + displaystring=": Manually" + if [ $switchduration -gt 1 ] + then + displaystring="Every $switchduration secs" + fi + + echo "-----------------------------" + echo "Argon OLED Configuration Tool" + echo "-----------------------------" + echo "Choose from the list:" + echo " 1. Switch Page $displaystring" + echo " 2. Configure Pages" + echo " 3. Turn OFF OLED Screen when unchanged after $screensaver secs" + echo " 4. Enable OLED Pages: $enabled" + echo + echo " 0. Back" + echo -n "Enter Number (0-3):" + + newmode=$( get_number ) + if [ $newmode -eq 0 ] + then + oledloopflag=0 + elif [ $newmode -eq 1 ] + then + echo + echo -n "Enter # of Seconds (10-60, Manual if 0):" + + cmdmode=$( get_number ) + if [ $cmdmode -eq 0 ] + then + switchduration=0 + updateconfig=1 + elif [[ $cmdmode -ge 10 && $cmdmode -le 60 ]] + then + updateconfig=1 + switchduration=$cmdmode + else + echo + echo "Invalid duration" + echo + fi + elif [ $newmode -eq 3 ] + then + echo + echo -n "Enter # of Seconds (60 or above, Manual if 0):" + + cmdmode=$( get_number ) + if [ $cmdmode -eq 0 ] + then + screensaver=0 + updateconfig=1 + elif [ $cmdmode -ge 60 ] + then + updateconfig=1 + screensaver=$cmdmode + else + echo + echo "Invalid duration" + echo + fi + elif [ $newmode -eq 2 ] + then + configure_pagelist "$screenlist" + if [ ! "$screenlist" == "$newscreenlist" ] + then + screenlist="$newscreenlist" + updateconfig=1 + fi + elif [ $newmode -eq 4 ] + then + echo + echo -n "Enable OLED Pages (Y/n)?:" + read -n 1 confirm + tmpenabled="$enabled" + if [[ "$confirm" == "n" || "$confirm" == "N" ]] + then + tmpenabled="N" + elif [[ "$confirm" == "y" || "$confirm" == "Y" ]] + then + tmpenabled="Y" + else + echo "Invalid response" + fi + if [ ! "$enabled" == "$tmpenabled" ] + then + enabled="$tmpenabled" + updateconfig=1 + fi + + fi + + if [ $updateconfig -eq 1 ] + then + saveconfig $enabled $switchduration $screensaver "$screenlist" + sudo systemctl restart argononed.service + fi +done + +echo diff --git a/source/scripts/argononeup-lidconfig.sh b/source/scripts/argononeup-lidconfig.sh new file mode 100644 index 0000000..bcc756a --- /dev/null +++ b/source/scripts/argononeup-lidconfig.sh @@ -0,0 +1,114 @@ +#!/bin/bash + +tmpfile="/dev/shm/argontmpconf.txt" +daemonconfigfile="/etc/argononeupd.conf" + +if [ -f "$daemonconfigfile" ] +then + . $daemonconfigfile +fi + +if [ -z "$lidshutdownsecs" ] +then + lidshutdownsecs=0 +fi + + +mainloopflag=1 +newmode=0 + + +get_number () { + read curnumber + if [ -z "$curnumber" ] + then + echo "-2" + return + elif [[ $curnumber =~ ^[+-]?[0-9]+$ ]] + then + if [ $curnumber -lt 0 ] + then + echo "-1" + return + fi + echo $curnumber + return + fi + echo "-1" + return +} + +while [ $mainloopflag -eq 1 ] +do + + lidshutdownmins=$((lidshutdownsecs / 60)) + + + echo "------------------------------------------" + echo " Argon One Up Lid Configuration Tool" + echo "------------------------------------------" + + echo + echo "Lid Close Behavior:" + if [ $lidshutdownsecs -lt 1 ] + then + echo "(Do Nothing)" + else + echo "(Shut down after $lidshutdownmins minute(s))" + fi + echo " 1. Do Nothing" + echo " 2. Shutdown" + echo + echo " 0. Exit" + echo "NOTE: You can also edit $daemonconfigfile directly" + echo -n "Enter Number (0-2):" + newmode=$( get_number ) + + if [[ $newmode -eq 0 ]] + then + mainloopflag=0 + elif [ $newmode -eq 1 ] + then + lidshutdownsecs=0 + elif [ $newmode -eq 2 ] + then + maxmins=120 + echo "Please provide number of minutes until shutdown:" + echo -n "Enter Number (1-$maxmins):" + curval=$( get_number ) + if [ $curval -gt $maxmins ] + then + newmode=0 + echo "Invalid input" + elif [ $curval -lt 1 ] + then + newmode=0 + echo "Invalid input" + else + lidshutdownsecs=$((curval * 60)) + fi + fi + + if [ $newmode -eq 1 ] || [ $newmode -eq 2 ] + then + if [ -f "$daemonconfigfile" ] + then + grep -v 'lidshutdownsecs' "$daemonconfigfile" > $tmpfile + else + echo '#' > $tmpfile + echo '# Argon One Up Configuration' >> $tmpfile + echo '#' >> $tmpfile + fi + echo '# lidshutdownsecs number of seconds till shutdown when lid is closed 0 if do nothing' >> $tmpfile + echo "lidshutdownsecs=$lidshutdownsecs" >> $tmpfile + + sudo cp $tmpfile $daemonconfigfile + sudo chmod 666 $daemonconfigfile + + echo "Configuration updated." + + fi +done + +echo + diff --git a/source/scripts/argononeupd.py b/source/scripts/argononeupd.py new file mode 100644 index 0000000..cb82727 --- /dev/null +++ b/source/scripts/argononeupd.py @@ -0,0 +1,474 @@ +#!/usr/bin/python3 + +# +# This script monitor battery via ic2 and keyboard events. +# +# Additional comments are found in each function below +# +# + +import sys +import os +import time + +from threading import Thread +from queue import Queue + +sys.path.append("/etc/argon/") +from argonregister import * +from argonpowerbutton import * + +# Initialize I2C Bus +bus = argonregister_initializebusobj() + +# Constants +ADDR_BATTERY = 0x64 + +UPS_LOGFILE="/dev/shm/upslog.txt" + + +################### +# Utilty Functions +################### + +# Debug Logger +def debuglog(typestr, logstr): + try: + DEBUGFILE="/dev/shm/argononeupdebuglog.txt" + tmpstrpadding = " " + + with open(DEBUGFILE, "a") as txt_file: + txt_file.write("["+time.asctime(time.localtime(time.time()))+"] "+typestr.upper()+" "+logstr.strip().replace("\n","\n"+tmpstrpadding)+"\n") + except: + pass + + +# System Notifcation +def notifymessage(message, iscritical): + if not isinstance(message, str) or len(message.strip()) == 0: + return + + wftype="notify" + if iscritical: + wftype="critical" + os.system("export SUDO_UID=1000; wfpanelctl "+wftype+" \""+message+"\"") + os.system("export DISPLAY=:0.0; lxpanelctl notify \""+message+"\"") + + +############# +# Battery +############# +REG_CONTROL = 0x08 +REG_SOCALERT = 0x0b +REG_PROFILE = 0x10 +REG_ICSTATE = 0xA7 + + + +def battery_restart(): + # Set to active mode + try: + maxretry = 3 + while maxretry > 0: + maxretry = maxretry - 1 + + # Restart + bus.write_byte_data(ADDR_BATTERY, REG_CONTROL, 0x30) + time.sleep(0.5) + # Activate + bus.write_byte_data(ADDR_BATTERY, REG_CONTROL, 0x00) + time.sleep(0.5) + + # Wait for Ready Status + maxwaitsecs = 5 + while maxwaitsecs > 0: + tmpval = bus.read_byte_data(ADDR_BATTERY, REG_ICSTATE) + if (tmpval&0x0C) != 0: + debuglog("battery-activate", "Activated Successfully") + return 0 + time.sleep(1) + maxwaitsecs = maxwaitsecs - 1 + + + debuglog("battery-activate", "Failed to activate") + return 2 + except Exception as e: + try: + debuglog("battery-activateerror", str(e)) + except: + debuglog("battery-activateerror", "Activation Failed") + return 1 + + +def battery_getstatus(restartifnotactive): + try: + tmpval = bus.read_byte_data(ADDR_BATTERY, REG_CONTROL) + if tmpval != 0: + if restartifnotactive == True: + tmpval = battery_restart() + + if tmpval != 0: + debuglog("battery-status", "Inactive "+str(tmpval)) + return 2 + + tmpval = bus.read_byte_data(ADDR_BATTERY, REG_SOCALERT) + if (tmpval&0x80) == 0: + debuglog("battery-status", "Profile not ready "+str(tmpval)) + return 3 + + # OK + #debuglog("battery-status", "OK") + return 0 + except Exception as e: + try: + debuglog("battery-status-error", str(e)) + except: + debuglog("battery-status-error", "Battery Status Failed") + + return 1 + +def battery_checkupdateprofile(): + try: + REG_GPIOCONFIG = 0x0A + + PROFILE_DATALIST = [0x32,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xA8,0xAA,0xBE,0xC6,0xB8,0xAE,0xC2,0x98,0x82,0xFF,0xFF,0xCA,0x98,0x75,0x63,0x55,0x4E,0x4C,0x49,0x98,0x88,0xDC,0x34,0xDB,0xD3,0xD4,0xD3,0xD0,0xCE,0xCB,0xBB,0xE7,0xA2,0xC2,0xC4,0xAE,0x96,0x89,0x80,0x74,0x67,0x63,0x71,0x8E,0x9F,0x85,0x6F,0x3B,0x20,0x00,0xAB,0x10,0xFF,0xB0,0x73,0x00,0x00,0x00,0x64,0x08,0xD3,0x77,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFA] + + PROFILE_LEN = len(PROFILE_DATALIST) + + # Try to compare profile if battery is active + tmpidx = 0 + + tmpval = battery_getstatus(True) + if tmpval == 0: + # Status OK, check profile + tmpidx = 0 + while tmpidx < PROFILE_LEN: + tmpval = bus.read_byte_data(ADDR_BATTERY, REG_PROFILE+tmpidx) + if tmpval != PROFILE_DATALIST[tmpidx]: + debuglog("battery-profile-error", "Mismatch") + break + tmpidx = tmpidx + 1 + + if tmpidx == PROFILE_LEN: + # Matched + return 0 + else: + debuglog("battery-profile", "Status Error "+str(tmpval)+", will attempt to update") + + # needs update + debuglog("battery-profile", "Updating...") + + # Device Sleep state + + # Restart + bus.write_byte_data(ADDR_BATTERY, REG_CONTROL, 0x30) + time.sleep(0.5) + # Sleep + bus.write_byte_data(ADDR_BATTERY, REG_CONTROL, 0xF0) + time.sleep(0.5) + + # Write Profile + tmpidx = 0 + while tmpidx < PROFILE_LEN: + bus.write_byte_data(ADDR_BATTERY, REG_PROFILE+tmpidx, PROFILE_DATALIST[tmpidx]) + tmpidx = tmpidx + 1 + + debuglog("battery-profile", "Profile Updated,Restarting...") + + # Set Update Flag + bus.write_byte_data(ADDR_BATTERY, REG_SOCALERT, 0x80) + time.sleep(0.5) + + # Close Interrupts + bus.write_byte_data(ADDR_BATTERY, REG_GPIOCONFIG, 0) + time.sleep(0.5) + + # Restart Battery + tmpval = battery_restart() + if tmpval == 0: + debuglog("battery-profile", "Update Completed") + return 0 + + debuglog("battery-profile", "Unable to restart") + return 3 + except Exception as e: + try: + debuglog("battery-profile-error", str(e)) + except: + debuglog("battery-profile-error", "Battery Profile Check/Update Failed") + + return 1 + + + +def battery_getpercent(): + # State of Charge (SOC) + try: + SOC_HIGH_REG = 0x04 + + socpercent = bus.read_byte_data(ADDR_BATTERY, SOC_HIGH_REG) + if socpercent > 100: + return 100 + elif socpercent > 0: + return socpercent + + # Support Fraction percent + #SOC_LOW_REG = 0x05 + #soc_low = bus.read_byte_data(ADDR_BATTERY, SOC_LOW_REG) + #socpercentfloat = socpercent + (soc_low / 256.0) + #if socpercentfloat > 100.0: + # return 100.0 + #elif socpercentfloat > 0.0: + # return socpercentfloat + + except Exception as e: + try: + debuglog("battery-percenterror", str(e)) + except: + debuglog("battery-percenterror", "Read Battery Failed") + + return -1 + + +def battery_isplugged(): + # State of Charge (SOC) + try: + CURRENT_HIGH_REG = 0x0E + + current_high = bus.read_byte_data(ADDR_BATTERY, CURRENT_HIGH_REG) + + if (current_high & 0x80) > 0: + return 1 + + #CURRENT_LOW_REG = 0x0F + #R_SENSE = 10.0 + #current_low = bus.read_byte_data(ADDR_BATTERY, CURRENT_LOW_REG) + #raw_current = int.from_bytes([current_high, current_low], byteorder='big', signed=True) + #current = (52.4 * raw_current) / (32768 * R_SENSE) + + return 0 + except Exception as e: + try: + debuglog("battery-chargingerror", str(e)) + except: + debuglog("battery-chargingerror", "Read Charging Failed") + + return -1 + +def battery_loadlogdata(): + # status, version, time, schedule + outobj = {} + try: + fp = open(UPS_LOGFILE, "r") + logdata = fp.read() + alllines = logdata.split("\n") + ctr = 0 + while ctr < len(alllines): + tmpval = alllines[ctr].strip() + curinfo = tmpval.split(":") + if len(curinfo) > 1: + tmpattrib = curinfo[0].lower().split(" ") + # The rest are assumed to be value + outobj[tmpattrib[0]] = tmpval[(len(curinfo[0])+1):].strip() + ctr = ctr + 1 + except OSError: + pass + + return outobj + +def battery_check(readq): + CMDSTARTBYTE=0xfe + CMDCONTROLBYTECOUNT=3 + CHECKSTATUSLOOPFREQ=50 + + CMDsendrequest = [ 0xfe, 0, 0, 0xfe, 0xfe, 0, 0, 0xfe, 0, 0, 0] + + lastcmdtime="" + loopCtr = CHECKSTATUSLOOPFREQ + sendcmdid = -1 + + debuglog("battery", "Starting") + + updatedesktopicon("Argon ONE UP", "/etc/argon/argon40.png") + + maxretry = 5 + while maxretry > 0: + try: + if battery_checkupdateprofile() == 0: + break + except Exception as mainerr: + try: + debuglog("battery-mainerror", str(mainerr)) + except: + debuglog("battery-mainerror", "Error") + # Give time before retry + time.sleep(10) + maxretry = maxretry - 1 + + while maxretry > 0: # Outer loop; maxretry never decrements so infinite + qdata = "" + if readq.empty() == False: + qdata = readq.get() + + if battery_getstatus(True) != 0: + # Give time before retry + time.sleep(3) + continue + + prevnotifymsg = "" + previconfile = "" + statusstr = "" + + shutdowntriggered=False + needsupdate=False + device_battery=0 + device_charging=0 + + while True: # Command loop + try: + if sendcmdid < 0: + cmddatastr = "" + + if cmddatastr == "": + if loopCtr >= CHECKSTATUSLOOPFREQ: + # Check Battery Status + sendcmdid = 0 + loopCtr = 0 + else: + loopCtr = loopCtr + 1 + if (loopCtr&1) == 0: + sendcmdid = 0 # Check Battery Status + + if sendcmdid == 0: + tmp_battery = battery_getpercent() + tmp_charging = battery_isplugged() + + if tmp_charging < 0 or tmp_battery < 0: + # communication error, retain old value + tmp_charging = device_charging + tmp_battery = device_battery + + if tmp_charging != device_charging or tmp_battery!=device_battery: + device_battery=tmp_battery + device_charging=tmp_charging + tmpiconfile = "/etc/argon/ups/" + needsupdate=True + curnotifymsg = "" + curnotifycritical = False + + if device_battery>99: + # Prevents switching issue + statusstr = "Charged" + curnotifymsg = statusstr + tmpiconfile = tmpiconfile+"charge_"+str(device_battery) + elif device_charging == 0: + statusstr = "Charging" + curnotifymsg = statusstr + tmpiconfile = tmpiconfile+"charge_"+str(device_battery) + else: + statusstr = "Battery" + tmpiconfile = tmpiconfile+"discharge_"+str(device_battery) + + if device_battery > 50: + curnotifymsg="Battery Mode" + elif device_battery > 20: + curnotifymsg="50%% Battery" + elif device_battery > 10: + curnotifymsg="20%% Battery" + elif device_battery > 5: + #curnotifymsg="Low Battery" + curnotifymsg="Low Battery: The device may power off automatically soon." + curnotifycritical=True + else: + curnotifymsg="CRITICAL BATTERY: Shutting Down in 1 minute" + curnotifycritical=True + + tmpiconfile = tmpiconfile + ".png" + statusstr = statusstr + " " + str(device_battery)+"%" + + # Add/update desktop icons too; add check to minimize write + if previconfile != tmpiconfile: + updatedesktopicon(statusstr, tmpiconfile) + previconfile = tmpiconfile + + # Send notification if necessary + if prevnotifymsg != curnotifymsg: + notifymessage(curnotifymsg, curnotifycritical) + if shutdowntriggered==False and device_battery <= 5 and device_charging != 0: + shutdowntriggered=True + os.system("shutdown +1 """+curnotifymsg+".""") + debuglog("battery-shutdown", "Shutdown in 1 minute") + + if shutdowntriggered==True and (device_charging == 0 or device_battery>=10): + shutdowntriggered=False + os.system("shutdown -c ""Charging, shutdown cancelled.""") + debuglog("battery-shutdown", "Abort") + + prevnotifymsg = curnotifymsg + + + sendcmdid=-1 + + if needsupdate==True: + # Log File + otherstr = "" + with open(UPS_LOGFILE, "w") as txt_file: + txt_file.write("Status as of: "+time.asctime(time.localtime(time.time()))+"\n Power:"+statusstr+"\n"+otherstr) + + needsupdate=False + + except Exception as e: + try: + debuglog("battery-error", str(e)) + except: + debuglog("battery-error", "Error") + break + time.sleep(3) + +def updatedesktopicon(statusstr, tmpiconfile): + try: + icontitle = "Argon ONE UP" + tmp = os.popen("find /home -maxdepth 1 -type d").read() + alllines = tmp.split("\n") + for curfolder in alllines: + if curfolder == "/home" or curfolder == "": + continue + #debuglog("desktop-update-path", curfolder) + #debuglog("desktop-update-text", statusstr) + #debuglog("desktop-update-icon", tmpiconfile) + with open(curfolder+"/Desktop/argononeup.desktop", "w") as txt_file: + txt_file.write("[Desktop Entry]\nName="+icontitle+"\nComment="+statusstr+"\nIcon="+tmpiconfile+"\nExec=lxterminal --working-directory="+curfolder+"/ -t \"Argon ONE UP\" -e \"/etc/argon/argon-config\"\nType=Application\nEncoding=UTF-8\nTerminal=false\nCategories=None;\n") + except Exception as desktope: + #pass + try: + debuglog("desktop-update-error", str(desktope)) + except: + debuglog("desktop-update-error", "Error") + + +if len(sys.argv) > 1: + cmd = sys.argv[1].upper() + if cmd == "GETBATTERY": + outobj = battery_loadlogdata() + try: + print(outobj["power"]) + except: + print("Error retrieving battery status") + elif cmd == "RESETBATTERY": + battery_checkupdateprofile() + + elif cmd == "SERVICE": + # Starts sudo level services + try: + ipcq = Queue() + if len(sys.argv) > 2: + cmd = sys.argv[2].upper() + t1 = Thread(target = battery_check, args =(ipcq, )) + t2 = Thread(target = argonpowerbutton_monitorlid, args =(ipcq, )) + + t1.start() + t2.start() + + ipcq.join() + except Exception: + sys.exit(1) diff --git a/source/scripts/argononeupd.service b/source/scripts/argononeupd.service new file mode 100644 index 0000000..6ba565b --- /dev/null +++ b/source/scripts/argononeupd.service @@ -0,0 +1,10 @@ +[Unit] +Description=Argon ONE UP Service +After=multi-user.target +[Service] +Type=simple +Restart=always +RemainAfterExit=true +ExecStart=/usr/bin/python3 /etc/argon/argononeupd.py SERVICE +[Install] +WantedBy=multi-user.target diff --git a/source/scripts/argononeupduser.service b/source/scripts/argononeupduser.service new file mode 100644 index 0000000..62481a3 --- /dev/null +++ b/source/scripts/argononeupduser.service @@ -0,0 +1,10 @@ +[Unit] +Description=Argon ONE UP Service +After=multi-user.target +[Service] +Type=simple +Restart=always +RemainAfterExit=true +ExecStart=/usr/bin/python3 /etc/argon/argonkeyboard.py SERVICE +[Install] +WantedBy=default.target diff --git a/source/scripts/argononeupsd.py b/source/scripts/argononeupsd.py new file mode 100644 index 0000000..48f3bff --- /dev/null +++ b/source/scripts/argononeupsd.py @@ -0,0 +1,106 @@ +#!/usr/bin/python3 + +import time +import os + +UPS_LOGFILE="/dev/shm/upslog.txt" +#UPS_DEVFILE="/dev/argonbatteryicon" + +def notifymessage(message, iscritical): + wftype="notify" + if iscritical: + wftype="critical" + os.system("export SUDO_UID=1000; wfpanelctl "+wftype+" \""+message+"\"") + os.system("export DISPLAY=:0.0; lxpanelctl notify \""+message+"\"") + +try: + outobj = {} + #os.system("insmod /etc/argon/ups/argonbatteryicon.ko") + prevnotifymsg="" + + tmp_battery = 100 + tmp_charging = 1 + + device_battery = -1 + device_charging = -1 + + while True: + try: + # Load status + fp = open(UPS_LOGFILE, "r") + logdata = fp.read() + alllines = logdata.split("\n") + ctr = 0 + while ctr < len(alllines): + tmpval = alllines[ctr].strip() + curinfo = tmpval.split(":") + if len(curinfo) > 1: + tmpattrib = curinfo[0].lower().split(" ") + # The rest are assumed to be value + outobj[tmpattrib[0]] = tmpval[(len(curinfo[0])+1):].strip() + ctr = ctr + 1 + + # Map to data + try: + statuslist = outobj["power"].lower().split(" ") + if statuslist[0] == "battery": + tmp_charging = 0 + else: + tmp_charging = 1 + tmp_battery = int(statuslist[1].replace("%","")) + except: + tmp_charging = device_charging + tmp_battery = device_battery + + # Update module data if changed + if tmp_charging != device_charging or tmp_battery!=device_battery: + device_charging = tmp_charging + device_battery = tmp_battery + + # No longer using default battery indicator + #try: + # with open(UPS_DEVFILE, 'w') as f: + # f.write("capacity = "+str(device_battery)+"\ncharging = "+str(device_charging)+"\n") + #except Exception as e: + # pass + + curnotifymsg = "" + curnotifycritical=False + + if tmp_charging: + if "Shutting Down" in prevnotifymsg: + os.system("shutdown -c ""Charging, shutdown cancelled.""") + + if tmp_battery > 99: + curnotifymsg="Fully Charged" + elif tmp_charging: + curnotifymsg="Charging" + else: + if tmp_battery > 50: + curnotifymsg="Battery Mode" + elif tmp_battery > 20: + curnotifymsg="50%% Battery" + elif tmp_battery > 10: + curnotifymsg="20%% Battery" + elif tmp_battery > 5: + #curnotifymsg="Low Battery" + curnotifymsg="Low Battery: The device may power off automatically soon." + curnotifycritical=True + else: + curnotifymsg="CRITICAL BATTERY: Shutting Down in 1 minute" + curnotifycritical=True + + + if prevnotifymsg != curnotifymsg: + notifymessage(curnotifymsg, curnotifycritical) + if tmp_battery <= 5 and tmp_charging != 0: + os.system("shutdown +1 """+curnotifymsg+".""") + + prevnotifymsg = curnotifymsg + + except OSError: + pass + time.sleep(60) +except: + pass + diff --git a/source/scripts/argononeupsd.service b/source/scripts/argononeupsd.service new file mode 100644 index 0000000..e5cd439 --- /dev/null +++ b/source/scripts/argononeupsd.service @@ -0,0 +1,14 @@ +[Unit] +Description=Argon Industria UPS Battery Service +DefaultDependencies=no + +[Install] +WantedBy=sysinit.target + +[Service] +Type=simple +KillSignal=SIGINT +TimeoutStopSec=8 +Restart=on-failure +WorkingDirectory=/etc/argon/ups/ +ExecStart=/usr/bin/python3 /etc/argon/argononeupsd.py diff --git a/source/scripts/argonupsrtcd.py b/source/scripts/argonupsrtcd.py new file mode 100644 index 0000000..3231442 --- /dev/null +++ b/source/scripts/argonupsrtcd.py @@ -0,0 +1,568 @@ +#!/usr/bin/python3 + +import json + +import sys +import datetime +import math + +import os +import time +import serial + +from threading import Thread +from queue import Queue + +sys.path.append("/etc/argon/") +import argonrtc + + +################# +# Common/Helpers +################# +#UPS_SERIALPORT="/dev/ttyUSB0" +UPS_SERIALPORT="/dev/ttyACM0" +UPS_LOGFILE="/dev/shm/upslog.txt" +UPS_CMDFILE="/dev/shm/upscmd.txt" + +RTC_CONFIGFILE = "/etc/argonupsrtc.conf" + + +############# +# RTC +############# + +def hexAsDec(hexval): + return (hexval&0xF) + 10*((hexval>>4)&0xf) + +def decAsHex(decval): + return (decval%10) + (math.floor(decval/10)<<4) + +# Returns RTC timestamp as datetime object +def getDatetimeObj(dataobj, datakey): + try: + datetimearray = dataobj[datakey].split(" ") + if len(datetimearray)>1: + datearray = datetimearray[0].split("/") + timearray = datetimearray[1].split(":") + if len(datearray) == 3 and len(timearray) > 1: + year = int(datearray[2]) + month = int(datearray[0]) + caldate = int(datearray[1]) + hour = int(timearray[0]) + minute = int(timearray[1]) + second = 0 + if len(timearray) > 2: + second = int(timearray[2]) + return datetime.datetime(year, month, caldate, hour, minute, second)+argonrtc.getLocaltimeOffset() + except: + pass + + return datetime.datetime(1999, 1, 1, 0, 0, 0) + + +def getRTCpoweronschedule(): + outobj = ups_sendcmd("7") + return getDatetimeObj(outobj, "schedule") + + +def getRTCdatetime(): + outobj = ups_sendcmd("5") + return getDatetimeObj(outobj, "time") + + +# set RTC time using datetime object (Local time) +def setRTCdatetime(): + # Set local time to UTC + outobj = ups_sendcmd("3") + return getDatetimeObj(outobj, "time") + + +# Set Next Alarm on RTC +def setNextAlarm(commandschedulelist, prevdatetime): + nextcommandtime, weekday, caldate, hour, minute = argonrtc.getNextAlarm(commandschedulelist, prevdatetime) + if prevdatetime >= nextcommandtime: + return prevdatetime + if weekday < 0 and caldate < 0 and hour < 0 and minute < 0: + # No schedule + # nextcommandtime is current time, which will be replaced/checked next iteration + return nextcommandtime + + # Convert to RTC timezone + alarmtime = nextcommandtime - argonrtc.getLocaltimeOffset() + + outobj = ups_sendcmd("6 "+alarmtime.strftime("%Y %m %d %H %M")) + return getDatetimeObj(outobj, "schedule") + + +############# +# Status +############# + +def ups_debuglog(typestr, logstr): + try: + UPS_DEBUGFILE="/dev/shm/upsdebuglog.txt" + + tmpstrpadding = " " + + with open(UPS_DEBUGFILE, "a") as txt_file: + txt_file.write("["+datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")+"] "+typestr.upper()+" "+logstr.strip().replace("\n","\n"+tmpstrpadding)+"\n") + except: + pass + +def ups_sendcmd(cmdstr): + # status, version, time, schedule + ups_debuglog("sendcmd", cmdstr) + try: + outstr = datetime.datetime.now().strftime("%Y%m%d%H%M%S") + + with open(UPS_CMDFILE, "w") as txt_file: + txt_file.write(datetime.datetime.now().strftime("%Y%m%d%H%M%S")+"\n"+cmdstr+"\n") + time.sleep(3) + except: + pass + + outobj = ups_loadlogdata() + try: + ups_debuglog("sendcmd-response", json.dumps(outobj)) + except: + pass + + return outobj + +def ups_loadlogdata(): + # status, version, time, schedule + outobj = {} + try: + fp = open(UPS_LOGFILE, "r") + logdata = fp.read() + alllines = logdata.split("\n") + ctr = 0 + while ctr < len(alllines): + tmpval = alllines[ctr].strip() + curinfo = tmpval.split(":") + if len(curinfo) > 1: + tmpattrib = curinfo[0].lower().split(" ") + # The rest are assumed to be value + outobj[tmpattrib[0]] = tmpval[(len(curinfo[0])+1):].strip() + ctr = ctr + 1 + except OSError: + pass + + return outobj + +def ups_check(readq): + CMDSTARTBYTE=0xfe + CMDCONTROLBYTECOUNT=3 + CHECKSTATUSLOOPFREQ=50 + + CMDsendrequest = [ 0xfe, 0, 0, 0xfe, 0xfe, 0, 0, 0xfe, 0, 0, 0] + + lastcmdtime="" + loopCtr = CHECKSTATUSLOOPFREQ + sendcmdid = -1 + + ups_debuglog("serial", "Starting "+UPS_SERIALPORT) + + updatedesktopicon("Argon UPS", "Argon UPS", "/etc/argon/ups/loading_0.png") + + while True: # Outer loop to reconnect to device + + qdata = "" + if readq.empty() == False: + qdata = readq.get() + + try: + ser = serial.Serial(UPS_SERIALPORT, 115200, timeout = 1) + ser.close() + ser.open() + except Exception as mainerr: + try: + ups_debuglog("serial-mainerror", str(mainerr)) + except: + ups_debuglog("serial-mainerror", "Error") + # Give time before retry + time.sleep(10) + continue + + + previconfile = "" + statusstr = "" + device_battery=0 + device_charging=0 + device_chargecurrent=-1 + device_version=-1 + device_rtctime= [-1, -1, -1, -1, -1, -1] + device_powerontime= [-1, -1, -1, -1, -1] + + while True: # Command loop + try: + if sendcmdid < 0: + cmddatastr = "" + try: + fp = open(UPS_CMDFILE, "r") + cmdlog = fp.read() + alllines = cmdlog.split("\n") + if len(alllines) > 1: + if lastcmdtime != alllines[0]: + lastcmdtime=alllines[0] + cmddatastr=alllines[1] + tmpcmdarray = cmddatastr.split(" ") + sendcmdid = int(tmpcmdarray[0]) + if sendcmdid == 3: + # Get/rebuild time here to minimize delay/time gap + newrtcdatetime = datetime.datetime.now() - argonrtc.getLocaltimeOffset() + cmddatastr = ("3 "+newrtcdatetime.strftime("%Y %m %d %H %M %S")) + tmpcmdarray = cmddatastr.split(" ") + if len(tmpcmdarray) != 7: + cmddatastr = "" + sendcmdid = 0 + elif sendcmdid == 6: + if len(tmpcmdarray) != 6: + cmddatastr = "" + sendcmdid = 0 + except OSError: + cmddatastr = "" + + if cmddatastr == "": + if loopCtr >= CHECKSTATUSLOOPFREQ: + # Check Battery Status + sendcmdid = 0 + loopCtr = 0 + else: + loopCtr = loopCtr + 1 + if loopCtr == 2: + sendcmdid = 5 # Get RTC Time + elif loopCtr == 3: + sendcmdid = 7 # Get Power on Time + elif loopCtr == 4: + sendcmdid = 4 # Get Version + elif loopCtr == 5: + sendcmdid = 2 # Get Charge Current + elif (loopCtr&1) == 0: + sendcmdid = 0 # Check Battery Status + + if sendcmdid >= 0: + sendSize = 0 + cmdSize = 0 + if len(cmddatastr) > 0: + # set RTC Time (3, 6 bytes) + # set Power of Time (6, 5 bytes) + tmpcmdarray = cmddatastr.split(" ") + CMDsendrequest[1] = len(tmpcmdarray) - 1 # Length + CMDsendrequest[2] = sendcmdid + + cmdSize = CMDsendrequest[1] + 4 + + # Copy payload + tmpdataidx = cmdSize - 1 # Start at end + while tmpdataidx > 3: + tmpdataidx = tmpdataidx - 1 + if tmpdataidx == 3 and (sendcmdid == 3 or sendcmdid == 6): + tmpval = int(tmpcmdarray[tmpdataidx-2]) + if tmpval >= 2000: + tmpval = tmpval - 2000 + else: + tmpval = 0 + CMDsendrequest[tmpdataidx] = decAsHex(tmpval) + else: + CMDsendrequest[tmpdataidx] = decAsHex(int(tmpcmdarray[tmpdataidx-2])) + + datasum = 0 + tmpdataidx = cmdSize - 1 + while tmpdataidx > 0: + tmpdataidx = tmpdataidx - 1 + datasum = (datasum+CMDsendrequest[tmpdataidx]) & 0xff + + CMDsendrequest[cmdSize-1] = datasum + sendSize = ser.write(serial.to_bytes(CMDsendrequest[0:cmdSize])) + + ups_debuglog("serial-out-cmd", serial.to_bytes(CMDsendrequest[0:cmdSize]).hex(" ")) + + else: + # Default Get/Read command + CMDsendrequest[1] = 0 # Length + CMDsendrequest[2] = sendcmdid + CMDsendrequest[3] = (sendcmdid+CMDsendrequest[0]) & 0xff + sendSize = ser.write(serial.to_bytes(CMDsendrequest[0:4])) + cmdSize = CMDsendrequest[1] + 4 + + #ups_debuglog("serial-out-def", serial.to_bytes(CMDsendrequest[0:4]).hex(" ")) + + if cmdSize > 0: + sendcmdid=-1 + if sendSize == cmdSize: + # Give time to respond + time.sleep(1) + else: + break + + # read incoming data + readOut = ser.read() + + if len(readOut) == 0: + continue + + readdatalen = 1 + while True: + tmpreadlen = ser.inWaiting() # Check remaining byte size + if tmpreadlen < 1: + break + readOut += ser.read(tmpreadlen) + readdatalen += tmpreadlen + + readintarray = [tmpint for tmpint in readOut] + + if len(cmddatastr) > 0: + ups_debuglog("serial-in ", readOut.hex(" ")) + cmddatastr = "" + # Parse command stream + tmpidx = 0 + while tmpidx < readdatalen: + if readintarray[tmpidx] == CMDSTARTBYTE and tmpidx + CMDCONTROLBYTECOUNT < readdatalen: + # Cmd format: Min 4 bytes + # tmpidx tmpidx+1 tmpidx+2 + # 0xfe (byte count) (cmd ID) (payload; byte count) (datasum) + + tmpdatalen = readintarray[tmpidx+1] + tmpcmd = readintarray[tmpidx+2] + if tmpidx + CMDCONTROLBYTECOUNT + tmpdatalen < readdatalen: + # Validate datasum + datasum = 0 + tmpdataidx = tmpidx + tmpdatalen + CMDCONTROLBYTECOUNT + while tmpdataidx > tmpidx: + tmpdataidx = tmpdataidx - 1 + datasum = (datasum+readintarray[tmpdataidx]) & 0xff + if datasum != readintarray[tmpidx + tmpdatalen + CMDCONTROLBYTECOUNT]: + # Invalid sum + pass + else: + needsupdate=False + if tmpcmd == 0: + # Check State + if tmpdatalen >= 2: + needsupdate=True + tmp_battery = readintarray[tmpidx+CMDCONTROLBYTECOUNT] + if tmp_battery>100: + tmp_battery=100 + elif tmp_battery<1: + tmp_battery=0 + tmp_charging = readintarray[tmpidx+CMDCONTROLBYTECOUNT+1] + #ups_debuglog("battery-data", str(tmp_charging)+" "+str(tmp_battery)) + + if tmp_charging != device_charging or tmp_battery!=device_battery: + device_battery=tmp_battery + device_charging=tmp_charging + tmpiconfile = "/etc/argon/ups/" + + icontitle = "Argon UPS" + if device_charging == 0: + if device_battery==100: + statusstr = "Charged" + #tmpiconfile = tmpiconfile+"battery_plug" + else: + #icontitle = str(device_battery)+"%"+" Full" + statusstr = "Charging" + #tmpiconfile = tmpiconfile+"battery_charging" + tmpiconfile = tmpiconfile+"charge_"+str(device_battery) + else: + #icontitle = str(device_battery)+"%"+" Left" + statusstr = "Battery" + tmp_battery = round(tmp_battery/20) + if tmp_battery > 4: + tmp_battery = 4 + #tmpiconfile = tmpiconfile+"battery_"+str(tmp_battery) + tmpiconfile = tmpiconfile+"discharge_"+str(device_battery) + tmpiconfile = tmpiconfile + ".png" + + statusstr = statusstr + " " + str(device_battery)+"%" + + #ups_debuglog("battery-info", statusstr) + + # Add/update desktop icons too; add check to minimize write + if previconfile != tmpiconfile: + updatedesktopicon(icontitle, statusstr, tmpiconfile) + previconfile = tmpiconfile + + elif tmpcmd == 2: + # Charge Current + if tmpdatalen >= 2: + device_chargecurrent = ((readintarray[tmpidx+CMDCONTROLBYTECOUNT])<<8) | readintarray[tmpidx+CMDCONTROLBYTECOUNT+1] + elif tmpcmd == 4: + # Version + if tmpdatalen >= 1: + needsupdate=True + device_version = readintarray[tmpidx+CMDCONTROLBYTECOUNT] + elif tmpcmd == 5: + # RTC Time + if tmpdatalen >= 6: + needsupdate=True + tmpdataidx = 0 + while tmpdataidx < 6: + device_rtctime[tmpdataidx] = hexAsDec(readintarray[tmpidx+CMDCONTROLBYTECOUNT+tmpdataidx]) + tmpdataidx = tmpdataidx + 1 + elif tmpcmd == 7: + # Power On Time + if tmpdatalen >= 5: + needsupdate=True + tmpdataidx = 0 + while tmpdataidx < 5: + device_powerontime[tmpdataidx] = hexAsDec(readintarray[tmpidx+CMDCONTROLBYTECOUNT+tmpdataidx]) + tmpdataidx = tmpdataidx + 1 + elif tmpcmd == 8: + # Send Acknowledge + sendcmdid = tmpcmd + elif tmpcmd == 3: + # New RTC Time set + sendcmdid = 5 + elif tmpcmd == 6: + # New Power On Time set + sendcmdid = 7 + + if needsupdate==True: + # Log File + otherstr = "" + if device_version >= 0: + otherstr = otherstr + " Version:"+str(device_version)+"\n" + if device_rtctime[0] >= 0: + otherstr = otherstr + " Time:"+str(device_rtctime[1])+"/"+str(device_rtctime[2])+"/"+str(device_rtctime[0]+2000)+" "+str(device_rtctime[3])+":"+str(device_rtctime[4])+":"+str(device_rtctime[5])+"\n" + if device_powerontime[1] > 0: + otherstr = otherstr + " Schedule:"+str(device_powerontime[1])+"/"+str(device_powerontime[2])+"/"+str(device_powerontime[0]+2000)+" "+str(device_powerontime[3])+":"+str(device_powerontime[4])+"\n" + with open(UPS_LOGFILE, "w") as txt_file: + txt_file.write("Status as of: "+time.asctime(time.localtime(time.time()))+"\n Power:"+statusstr+"\n"+otherstr) + #ups_debuglog("status-update", "\n Power:"+statusstr+"\n"+otherstr) + # Point to datasum, so next loop iteration will be correct + tmpidx = tmpidx + tmpdatalen + CMDCONTROLBYTECOUNT + tmpidx = tmpidx + 1 + except Exception as e: + try: + ups_debuglog("serial-error", str(e)) + except: + ups_debuglog("serial-error", "Error") + break + +def updatedesktopicon(icontitle, statusstr, tmpiconfile): + try: + tmp = os.popen("find /home -maxdepth 1 -type d").read() + alllines = tmp.split("\n") + for curfolder in alllines: + if curfolder == "/home" or curfolder == "": + continue + #ups_debuglog("desktop-update-path", curfolder) + #ups_debuglog("desktop-update-text", statusstr) + #ups_debuglog("desktop-update-icon", tmpiconfile) + with open(curfolder+"/Desktop/argonone-ups.desktop", "w") as txt_file: + txt_file.write("[Desktop Entry]\nName="+icontitle+"\nComment="+statusstr+"\nIcon="+tmpiconfile+"\nExec=lxterminal --working-directory="+curfolder+"/ -t \"Argon UPS\" -e \"/etc/argon/argonone-upsconfig.sh argonupsrtc\"\nType=Application\nEncoding=UTF-8\nTerminal=false\nCategories=None;\n") + except Exception as desktope: + #pass + try: + ups_debuglog("desktop-update-error", str(desktope)) + except: + ups_debuglog("desktop-update-error", "Error") + + +def allowshutdown(): + uptime = 0.0 + errorflag = False + try: + cpuctr = 0 + tempfp = open("/proc/uptime", "r") + alllines = tempfp.readlines() + for temp in alllines: + infolist = temp.split(" ") + if len(infolist) > 1: + uptime = float(infolist[0]) + break + tempfp.close() + except IOError: + errorflag = True + # 120=2mins minimum up time + return uptime > 120 + + +###### +if len(sys.argv) > 1: + cmd = sys.argv[1].upper() + if cmd == "GETBATTERY": + #outobj = ups_sendcmd("0") + outobj = ups_loadlogdata() + try: + print(outobj["power"]) + except: + print("Error retrieving battery status") + + elif cmd == "GETRTCSCHEDULE": + tmptime = getRTCpoweronschedule() + if tmptime.year > 1999: + print("Alarm Setting:", tmptime) + else: + print("Alarm Setting: None") + + elif cmd == "GETRTCTIME": + tmptime = getRTCdatetime() + if tmptime.year > 1999: + print("RTC Time:", tmptime) + else: + print("Error reading RTC Time") + + elif cmd == "UPDATERTCTIME": + tmptime = setRTCdatetime() + if tmptime.year > 1999: + print("RTC Time:", tmptime) + else: + print("Error reading RTC Time") + + elif cmd == "GETSCHEDULELIST": + argonrtc.describeConfigList(RTC_CONFIGFILE) + + elif cmd == "SHOWSCHEDULE": + if len(sys.argv) > 2: + if sys.argv[2].isdigit(): + # Display starts at 2, maps to 0-based index + configidx = int(sys.argv[2])-2 + configlist = argonrtc.loadConfigList(RTC_CONFIGFILE) + if len(configlist) > configidx: + print (" ",argonrtc.describeConfigListEntry(configlist[configidx])) + else: + print(" Invalid Schedule") + + elif cmd == "REMOVESCHEDULE": + if len(sys.argv) > 2: + if sys.argv[2].isdigit(): + # Display starts at 2, maps to 0-based index + configidx = int(sys.argv[2])-2 + argonrtc.removeConfigEntry(RTC_CONFIGFILE, configidx) + + elif cmd == "SERVICE": + ipcq = Queue() + + tmprtctime = getRTCdatetime() + if tmprtctime.year >= 2000: + argonrtc.updateSystemTime(tmprtctime) + commandschedulelist = argonrtc.formCommandScheduleList(argonrtc.loadConfigList(RTC_CONFIGFILE)) + nextrtcalarmtime = setNextAlarm(commandschedulelist, datetime.datetime.now()) + + t1 = Thread(target = ups_check, args =(ipcq, )) + t1.start() + + serviceloop = True + while serviceloop==True: + tmpcurrenttime = datetime.datetime.now() + if nextrtcalarmtime <= tmpcurrenttime: + # Update RTC Alarm to next iteration + nextrtcalarmtime = setNextAlarm(commandschedulelist, nextrtcalarmtime) + if len(argonrtc.getCommandForTime(commandschedulelist, tmpcurrenttime, "off")) > 0: + # Shutdown detected, issue command then end service loop + if allowshutdown(): + os.system("shutdown now -h") + serviceloop = False + # Don't break to sleep while command executes (prevents service to restart) + + time.sleep(60) + + ipcq.join() + + +elif False: + print("System Time: ", datetime.datetime.now()) + print("RTC Time: ", getRTCdatetime()) diff --git a/source/scripts/argonupsrtcd.service b/source/scripts/argonupsrtcd.service new file mode 100644 index 0000000..5f14750 --- /dev/null +++ b/source/scripts/argonupsrtcd.service @@ -0,0 +1,10 @@ +[Unit] +Description=Argon UPS RTC Service +After=multi-user.target +[Service] +Type=simple +Restart=always +RemainAfterExit=true +ExecStart=/usr/bin/python3 /etc/argon/argonupsrtcd.py SERVICE +[Install] +WantedBy=multi-user.target diff --git a/source/ups/battery_0.png b/source/ups/battery_0.png new file mode 100644 index 0000000000000000000000000000000000000000..e81a06d20babcac52145b9da2b6f987a0de78ccf GIT binary patch literal 136 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnH3?%tPCZz)@o&cW^S0D`nj12!77`6#&od&T> zg8YIR9G=}s19G%HT^vIsE+;1(VA~@0ASq*Q6lTSyihKfJe@ Zn}H#e*FZPq&Te&(d7iF*F6*2UngE~DBWnNv literal 0 HcmV?d00001 diff --git a/source/ups/battery_4.png b/source/ups/battery_4.png new file mode 100644 index 0000000000000000000000000000000000000000..724b7024988e1707726324953fe585c6aaf62447 GIT binary patch literal 127 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnH3?%tPCZz)@&H$efS0K&6$iVQQL08?D3B)c5 z@(X5gcy=QV$WifhaSV~ToSblg&4szZ(8bwVQHRlhiD_Zz>NXkXcNIIz3m6%0GAkV0 S`mJCN$Rtl!KbLh*2~7aM=N*6m literal 0 HcmV?d00001 diff --git a/source/ups/battery_alert.png b/source/ups/battery_alert.png new file mode 100644 index 0000000000000000000000000000000000000000..47adc9b0990be27ec100f3c41233c37d209d07f4 GIT binary patch literal 139 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnH3?%tPCZz)@o&cW^S0D`nObq}3GbBx2n+aqy zmIV0)GdMiEkp|@Gdb&7_Jk-+9=jU%?3dMjsO4qCx6VgpDOv}X6r(U ecNIIz3m6$*IU9UFk!ku4WTdC7pUXO@geCx~8Yu1n literal 0 HcmV?d00001 diff --git a/source/ups/battery_charging.png b/source/ups/battery_charging.png new file mode 100644 index 0000000000000000000000000000000000000000..d5bc61a8c324c861aa0cf0fdcadb99dd63e71354 GIT binary patch literal 161 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnH3?%tPCZz)@o&cW^S0D`n{~7-L|NTp1jR%m; zSQ6wH%;50sMjDXg?CIhdB5^r6;Q*TpbAh3Yv$LWOqX84s!qC+P#&M34s|_Vy8%q3s z*i#>Htlr|WxX(xZIk&E_cS>J#_@%*)#}DrvWM$A4R^^uZ*y#hZ$kWx&Wt~$(69B5_ BF&qE@ literal 0 HcmV?d00001 diff --git a/source/ups/battery_plug.png b/source/ups/battery_plug.png new file mode 100644 index 0000000000000000000000000000000000000000..c27055ab869b71f4724f4343b1d6aee4f56a58fa GIT binary patch literal 142 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnH3?%tPCZz)@&H$efS0K&6#PIk3pX3RWX+Sn( zNswPKgTu2MAda!8i(`ny<>Z6|Y%a_NhAz&|iaLx2OiT+yS6`6aoc1uKrETKjh*_?( iY-d^vjvwCJ%+0`1C@JMJ^-1Y6kinj=elF{r5}E-011DJk literal 0 HcmV?d00001 diff --git a/source/ups/battery_unknown.png b/source/ups/battery_unknown.png new file mode 100644 index 0000000000000000000000000000000000000000..c7908efbe5abbb8ec1b29ae428b4a24e6ab10d86 GIT binary patch literal 161 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnH3?%tPCZz)@o&cW^S0D`pX=!Qy|Nnn{^!i62 zhp{BcFPOpM*^M+H$Jx`xF+}2Wa>4;N7v=&(7iVWh9YzBtriG!a3yi+)Z!(H|+hlm{ z;FScUgsiZ=PU&mnTF)KWl5NYzy#4TlzV>Q%aR!F#aZ17LVi&i9Z1QyVb6Mw<&;$T% CEHUi> literal 0 HcmV?d00001 diff --git a/source/ups/upsimg.tar.gz b/source/ups/upsimg.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..5d039d4c5e010291b460335fc9100ae8f11cd831 GIT binary patch literal 60389 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 V_H_jImw*5irFw}^3dDf``X5^@sagO4 literal 0 HcmV?d00001