Last active
May 8, 2025 07:14
-
-
Save russdill/3e4b390580e85f3e0ff60490eba48ab6 to your computer and use it in GitHub Desktop.
Airvisual Pro Outdoor Samba Support
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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