Skip to content

Instantly share code, notes, and snippets.

@russdill
Last active May 8, 2025 07:14
Show Gist options
  • Save russdill/3e4b390580e85f3e0ff60490eba48ab6 to your computer and use it in GitHub Desktop.
Save russdill/3e4b390580e85f3e0ff60490eba48ab6 to your computer and use it in GitHub Desktop.
Airvisual Pro Outdoor Samba Support
#!/bin/sh
BASENAME="$(dirname $0)"
pw="EDIT_ME"
# Instructions:
# * Edit the samba pw (pw=) field
# * Place this script (addssh.sh) in the avossh/ directory of a VFAT formatted
# microsd card.
# * Place the microsd card into a powered Airvisual Pro outdoor unit.
# * After a few seconds, the red LED near the microsd slot will flash about
# seven times indicating completion.
# * Remove the microSD card and check the log.txt file in the avossh/ directory,
# If it indicates 'done' on the last line, all is well.
if [ -e $BASENAME/log.txt ]; then
echo $BASENAME/log.txt already exists, aborting >> $BASENAME/log.txt
exit
fi
exec 1<&-
exec 2<&-
exec 1<>$BASENAME/log.txt
exec 2>&1
DB="/opt/app/app/Databases/73b6aac6d82803e15200b4030f19d964.sqlite"
if [ ! -e $DB ]; then
echo Database $DB not present, aborting
exit
fi
sn=$(echo 'SELECT "value" FROM "defaultsys" WHERE "type" is "serialnumber";' | sqlite3 $DB)
if [ -z "$sn" ]; then
echo Could not read SN, aborting
exit
fi
if [ -e /etc/init.d/S92smb ]; then
echo /etc/init.d/S92smb already exists, aborting
exit
fi
if [ -e /etc/init.d/S90update_json.py ]; then
echo /etc/init.d/S90update_json.py already exists, aborting
exit
fi
if [ -e /etc/init.d/S91smb ]; then
echo /etc/init.d/S91smb exists, aborting
exit
fi
if [ ! -e /etc/samba/smb.conf ]; then
echo /etc/samba/smb.conf does not exist, aborting
exit
fi
if ! grep -q ' wlan0$' /etc/samba/smb.conf; then
echo Unexpected content of /etc/samba/smb.conf, aborting
cat /etc/samba/smb.conf
exit
fi
if ! grep -q '^netbios name = AIRVISUAL$' /etc/samba/smb.conf; then
echo Unexpected content of /etc/samba/smb.conf, aborting
cat /etc/samba/smb.conf
exit
fi
if ! grep -q '^exit \$RETVAL$' /S30dbus; then
echo Unexpected content of /S30dbus, aborting
cat /S30dbus
exit
fi
if id airvisual 2>/dev/null; then
echo airvisual user already exists, aborting
exit
fi
if id -g airvisual 2>/dev/null; then
echo airvisual group already exists, aborting
exit
fi
echo Prechecks passed, applying changes...
# Add the airvisual user
adduser -D -H airvisual
# Add the smbpasswd for the airvisual user
echo -ne "$pw\n$pw\n" | smbpasswd -a -s airvisual
# Modify the samba config to also connect to eth0 and to change the netbios
# name to contain the serial number.
sed -e 's/ wlan0$/ wlan0 eth0/g' -e "s/^netbios name = AIRVISUAL\$/netbios name = AIRVISUAL-$sn/g" -i /etc/samba/smb.conf
# A race condition exists between starting samba and start the airvisual app.
# For wifi connections it's fine, for ethernet connections a failure occurs.
# Hook onto /S30dbus to restart sambas as well as the airvisual app does this
# at startup.
sed -e 's,^exit \$RETVAL$,/etc/init.d/S92smb $1\nexit $RETVAL,g' -i /S30dbus
# Samba init.d script
(
cat<<'EOF'
#!/bin/sh
[ -f /etc/samba/smb.conf ] || exit 0
mkdir -p /var/log/samba
start() {
printf "Starting SMB services: "
smbd -D
[ $? = 0 ] && echo "OK" || echo "FAIL"
printf "Starting NMB services: "
nmbd -D
[ $? = 0 ] && echo "OK" || echo "FAIL"
}
stop() {
printf "Shutting down SMB services: "
kill -9 `pidof smbd`
[ $? = 0 ] && echo "OK" || echo "FAIL"
printf "Shutting down NMB services: "
kill -9 `pidof nmbd`
[ $? = 0 ] && echo "OK" || echo "FAIL"
rm -rf /var/lib/samba/private/msg.sock/*
}
restart() {
stop
start
}
reload() {
printf "Reloading smb.conf file: "
kill -HUP `pidof smbd`
[ $? = 0 ] && echo "OK" || echo "FAIL"
}
case "$1" in
start)
start
;;
stop)
stop
;;
restart)
restart
;;
reload)
reload
;;
*)
echo "Usage: $0 {start|stop|restart|reload}"
exit 1
esac
exit $?
EOF
) > /etc/init.d/S92smb
chmod +x /etc/init.d/S92smb
# Script to generate the json
(
cat<<'EOF'
#!/usr/bin/python2
import subprocess
import pipes
import json
import time
import datetime
import os
import re
import sys
DB_PATH = '/opt/app/app/Databases/73b6aac6d82803e15200b4030f19d964.sqlite'
aqi_tables = {
'PM25_US': (1, [0, 9.1, 35.5, 55.5, 125.5, 225.5, 325.5]),
'PM25_CN': (0, [0, 35, 75, 115, 150, 250, 350, 500]),
'PM10_US': (0, [0, 55, 155, 255, 355, 425, 605]),
'PM10_CN': (0, [0, 50, 150, 250, 350, 420, 500, 600]),
}
def calc_aqi(c, table):
rnd, row = aqi_tables[table]
c = round(max(c, 0), rnd)
last_n = 0
for i, n in enumerate(row):
if c < n:
break
last_n = n
return int(round((c - last_n) * 49.0 / (n - pow(10, -rnd) - last_n) + i * 50 - 49))
def fetch_table(table):
sql = 'SELECT type, value FROM ' + table + ';'
cmd = "sqlite3 -separator '\t' -batch -noheader {} {}".format(pipes.quote(DB_PATH), pipes.quote(sql))
try:
output = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as exc:
raise RuntimeError(
'sqlite3 exited with status {}\n{}\nSQL was:\n{}'.format(
exc.returncode, exc.output, sql
)
)
lines = output.decode('utf-8').rstrip('\n').split('\n') if output else []
return dict(line.split('\t', 1) for line in lines)
def try_int(s, d=0):
try:
return int(s)
except:
return d
def try_float(s, d=0.0):
try:
return float(s)
except:
return d
pattern = re.compile(r'^([A-Za-z0-9]+)=(.*)$')
version_data = {}
with open('/Pix-Version', 'r') as fh:
for line in fh:
m = pattern.match(line.rstrip('\r\n'))
if m:
key, value = m.groups()
version_data[key] = value
defaultsys = fetch_table('defaultsys')
def run():
settings = fetch_table('settings')
output = subprocess.check_output(['ip', '-o', '-4', 'a', 's'])
for line in output.splitlines():
args = line.split()
iface = args[1]
if iface == 'lo':
continue
ip_address = args[3].split('/')[0]
with open('/sys/class/net/' + iface + '/address') as fh:
mac_address = fh.read().strip().lower()
break
used_memory = subprocess.check_output(['df', '-h', '/opt/app/disk']).splitlines()[1].split()[4][:-1]
wifi_strength = 0
with open('/proc/net/wireless', 'r') as fh:
for line in fh:
args = line.strip().split()
if args[0] == iface:
v = int(float(args[3]))
if v < -46:
wifi_strength = 5
elif v < -46:
wifi_strength = 4
elif v < -61:
wifi_strength = 3
elif v < -77:
wifi_strength = 2
else:
wifi_strength = 1
break
d = datetime.datetime.utcnow()
year = d.year
month = d.month
measurements_file = '/opt/app/disk/measurements/{:04}{:02}_AirVisual_values.txt'.format(year, month)
if not os.path.isfile(measurements_file):
month -= 1
if not month:
month = 12
year -= 1
measurements_file = '/opt/app/disk/measurements/{:04}{:02}_AirVisual_values.txt'.format(year, month)
with open(measurements_file, 'rb') as fh:
data = fh.read(512)
end = data.find(b'\n')
if end == -1:
raise Exception('No measurement data')
hdr = data[:end]
fh.seek(-512, os.SEEK_END)
data = fh.read()
second = data.rfind(b'\n')
first = data.rfind(b'\n', 0, second)
if second == -1 or first == -1:
raise Exception('No measurement data')
data = data[first+1:second]
names = hdr.split(';')
fields = data.split(';', len(names) - 1)
measurements = dict(zip(names, fields))
sensors = fields[-1].split(';')
sensors = dict(zip(sensors[::2], sensors[1::2]))
if settings.get('station', '0') == '0':
if 'pm25' in sensors:
pm25_AQICN = calc_aqi(try_float(sensors['pm25']), 'PM25_CN')
pm25_AQIUS = calc_aqi(try_float(sensors['pm25']), 'PM25_US')
else:
pm25_AQIUS, pm25_AQICN = None, None
if 'pm10' in sensors:
pm10_AQICN = calc_aqi(try_float(sensors['pm10']), 'PM10_CN')
pm10_AQIUS = calc_aqi(try_float(sensors['pm10']), 'PM10_US')
else:
pm10_AQIUS, pm10_AQICN = None, None
if 'pm25' and 'pm10' in sensors:
pm_AQIUS = str(max(pm10_AQIUS, pm25_AQIUS))
pm_AQICN = str(max(pm10_AQICN, pm25_AQICN))
pm25_AQIUS, pm25_AQICN = str(pm25_AQIUS), str(pm25_AQICN)
pm10_AQIUS, pm10_AQICN = str(pm10_AQIUS), str(pm10_AQICN)
elif 'pm25' in sensors:
pm25_AQIUS, pm25_AQICN = str(pm25_AQIUS), str(pm25_AQICN)
pm_AQIUS, pm_AQICN = pm25_AQIUS, pm25_AQICN
elif 'pm10' in sensors:
pm10_AQIUS, pm10_AQICN = str(pm10_AQIUS), str(pm10_AQICN)
pm_AQIUS, pm_AQICN = pm10_AQIUS, pm10_AQICN
else:
pm_AQIUS, pm_AQICN = None, None
else:
pm25_AQIUS = measurements.get('Outdoor AQI(US)', None)
pm25_AQICN = measurements.get('Outdoor AQI(CN)', None)
pm10_AQIUS, pm10_AQICN = None, None
pm_AQIUS, pm_AQICN = pm25_AQIUS, pm25_AQICN
data = {
'date_and_time': {
'date': measurements.get('Date', ''),
'time': measurements.get('Time', ''),
'timestamp': try_int(measurements.get('Timestamp', 0))
},
'measurements': [
{
'co2_ppm': sensors.get('co2', None),
'humidity_RH': measurements.get('Humidity(%RH)', ''),
'pm01_ugm3': sensors.get('pm1', None),
'pm10_ugm3': sensors.get('pm10', None),
'pm25_AQICN': pm25_AQICN,
'pm25_AQIUS': pm25_AQIUS,
'pm10_AQICN': pm10_AQICN,
'pm10_AQIUS': pm10_AQIUS,
'pm_AQICN': pm_AQICN,
'pm_AQIUS': pm_AQIUS,
'pm25_ugm3': sensors.get('pm25', None),
'temperature_C': measurements.get('Temperature(C)', ''),
'temperature_F': measurements.get('Temperature(F)', ''),
'pressure': measurements.get('Pressure', None),
'voc_ppb': sensors.get('voc', None)
}
],
'serial_number': defaultsys.get('serialnumber', ''),
'settings': {
'follow_mode': settings.get('followmode', 'station'),
'followed_station': settings.get('station', '0'),
'is_aqi_usa': settings.get('aqi', 'usa') == 'usa',
'is_concentration_showed': settings.get('aqiunits', None) == 'aqi',
'is_indoor': not settings.get('isoutdoornode', True),
'is_lcd_on': settings.get('brightness', -1) != -1,
'is_network_time': settings.get('autosettime', False),
'is_temperature_celsius': settings.get('istemperaturecelsius', True),
'language': settings.get('language', ''),
'lcd_brightness': try_int(settings.get('brightness', -1), -1),
'node_name': settings.get('nodename', 'Outdoor'),
'power_saving': {
'2slots': [
{
'hour_off': try_int(settings.get('morningoff', 0)),
'hour_on': try_int(settings.get('morningon', 0))
},
{
'hour_off': try_int(settings.get('afternoonoff', 0)),
'hour_on': try_int(settings.get('afternoonon', 0))
}
],
'mode': settings.get('savingmodestate', 'no'),
'running_time': 99,
'yes': [
{
'hour': try_int(settings.get('autoonhour', 0)),
'minute': try_int(settings.get('autoonminutes', 0))
},
{
'hour': try_int(settings.get('autooffhour', 0)),
'minute': try_int(settings.get('autooffminutes', 0))
}
]
},
'sensor_mode': {
'custom_mode_interval': try_int(settings.get('custommodeinterval', 0)),
'mode': try_int(settings.get('continumeasure', 0))
},
'speed_unit': '',
'timezone': settings.get('timezone', '0'),
'tvoc_unit': settings.get('tvocunit', '')
},
'status': {
'app_version': '2.0.' + defaultsys.get('appversion', '???'),
'battery': None,
'datetime': int(time.time()),
'device_name': 'AIRVISUAL-' + defaultsys.get('serialnumber', ''),
'ip_address': ip_address,
'mac_address': mac_address,
'model': '40',
'system_version': 'K' + version_data.get('kernel', '00') + 'F' + version_data.get('rootfs', '00'),
'used_memory': try_int(used_memory),
'wifi_strength': wifi_strength
}
}
with open('/opt/app/disk/measurements/latest_config_measurements.json.new', 'w') as fh:
fh.write(json.dumps(data))
os.rename('/opt/app/disk/measurements/latest_config_measurements.json.new', '/opt/app/disk/measurements/latest_config_measurements.json')
pid = os.fork()
if pid > 0:
sys.exit(0)
os.chdir('/')
os.setsid()
pid = os.fork()
if pid > 0:
sys.exit(0)
for f in sys.stdout, sys.stderr: f.flush( )
si = file('/dev/null', 'r')
so = file('/dev/null', 'a+')
se = file('/dev/null', 'a+', 0)
os.dup2(si.fileno(), sys.stdin.fileno())
os.dup2(so.fileno(), sys.stdout.fileno())
os.dup2(se.fileno(), sys.stderr.fileno())
os.umask(0o022)
while True:
try:
run()
except Exception as e:
print(e)
time.sleep(60)
EOF
) > /etc/init.d/S90update_json.py
chmod +x /etc/init.d/S90update_json.py
# Go
/etc/init.d/S90update_json.py
/etc/init.d/S92smb start
echo done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment