Initial commit

This commit is contained in:
Mike Cifelli 2023-02-07 08:49:20 -05:00
commit 8cdffa444f
18 changed files with 918 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.vscode/
*.swp

55
install-on-device-fs Executable file
View File

@ -0,0 +1,55 @@
#!/usr/bin/env bash
DEVICES=$(mpremote connect list | grep MicroPython | cut -d " " -f 1)
if [ -z $DEVICES ] ; then
echo "No MicroPython devices found in FS mode"
exit 1
fi
DEVICE=${DEVICES[0]}
echo "Copying firmware files to ${DEVICE}"
function create_directory {
echo -n "> creating directory $1"
RESULT=$(mpremote connect ${DEVICE} mkdir $1)
ERROR=$?
if [ $ERROR -eq 0 ] ; then
echo " .. done!"
else
if [[ "$RESULT" == *"EEXIST"* ]] ; then
echo " .. already exists, skipping."
else
echo " .. failed!"
echo "! it looks like this device is already in use - is Thonny running?"
exit 1
fi
fi
}
function copy {
for file in $1
do
echo -n "> copying file $file"
mpremote connect ${DEVICE} cp $file $2 > /dev/null
if [ $? -eq 0 ] ; then
echo " .. done!"
else
echo " .. failed!"
fi
done
}
create_directory networking
create_directory sensors
create_directory www
copy "main.py" :
copy "networking/*.py" :networking/
copy "sensors/*.py" :sensors/
copy "www/*" :www/

88
main.py Normal file
View File

@ -0,0 +1,88 @@
import json
import time
from machine import Pin
from networking import AdafruitIO
from networking import logging
from networking import ntp
from networking import Server
from networking import templates
from networking import util
class LedServer(Server):
def __init__(self):
super().__init__()
self.led = Pin('LED', Pin.OUT)
self.aio = AdafruitIO()
self.ntp_interval_in_seconds = 60 * 60
self.aio_interval_in_seconds = 60 * 5
self.ntp_ticks = time.ticks_ms()
self.aio_ticks = time.ticks_ms()
ntp.sync()
def cleanup(self):
super().cleanup()
self.led.off()
def work(self):
super().work()
ticks = time.ticks_ms()
if util.secondsElapsed(ticks, self.ntp_ticks) > self.ntp_interval_in_seconds:
self.ntp_ticks = ticks
ntp.sync()
if util.secondsElapsed(ticks, self.aio_ticks) > self.aio_interval_in_seconds:
self.aio_ticks = ticks
self.aio.upload(self.getReading())
def handlePath(self, path):
if path == 'index.json':
return self.getJsonData()
elif path == 'light/on':
self.led.on()
return self.getJsonData()
elif path == 'light/off':
self.led.off()
return self.getJsonData()
return self.getPathData(path)
def getJsonData(self):
return json.dumps(self.getState())
def getPathData(self, path):
if path.endswith('.txt') or path.endswith('.ico'):
with open(f'www/{path}', 'rb') as f:
return f.read()
return templates.render(
f'www/{path or self.default_path}',
ledStatus=self.ledTextValue(),
ledClass=self.ledCssClass(),
datetime=util.datetime()
)
def getReading(self):
return {'timestamp': util.datetimeISO8601(), 'readings': self.getState()}
def getState(self):
return {'is-led-on': bool(self.led.value())}
def ledTextValue(self):
return 'ON' if self.led.value() else 'OFF'
def ledCssClass(self):
return 'led-on' if self.led.value() else 'led-off'
def main():
logging.log_file = 'www/log.txt'
LedServer().run()
if __name__ == '__main__':
main()

2
networking/__init__.py Normal file
View File

@ -0,0 +1,2 @@
from .adafruit_io import AdafruitIO
from .server import Server

68
networking/adafruit_io.py Normal file
View File

@ -0,0 +1,68 @@
import urequests
from . import logging
from .config import config
from .config import secrets
class AdafruitIO:
def __init__(self):
self.nickname = config['adafruit_io_nickname']
self.headers = {
'x-aio-key': secrets['adafruit_io_key'],
'content-type': 'application/json'
}
group = config['adafruit_io_group']
username = secrets['adafruit_io_username']
self.url = f'https://io.adafruit.com/api/v2/{username}/groups/{group}/data'
def upload(self, reading):
result = urequests.post(
self.url,
json=self.createPayload(reading),
headers=self.headers
)
try:
if result.status_code != 200:
self.logError(result)
return False
return True
finally:
result.close()
def createPayload(self, reading):
payload = {
'created_at': reading['timestamp'],
'feeds': []
}
for key, value in reading['readings'].items():
payload['feeds'].append({
'key': f'{self.nickname}-{key}',
'value': str(value)
})
return payload
def logError(self, result):
reason = 'unknown'
error_message = self.getErrorMessage(result)
if result.status_code == 429:
reason = 'rate limited'
elif result.status_code == 422 and error_message.find('data created_at may not be in the future') == 0:
reason = 'future reading'
else:
reason = f'{result.status_code} - {result.reason.decode("utf-8")}'
logging.debug(f'upload issue: {reason} - "{error_message}"')
def getErrorMessage(self, result):
try:
return result.json()['error']
except (TypeError, KeyError):
return ''

View File

@ -0,0 +1,11 @@
config = {
'adafruit_io_nickname': '',
'adafruit_io_group': ''
}
secrets = {
'ssid': 'ssid',
'password': 'password',
'adafruit_io_username': '',
'adafruit_io_key': ''
}

6
networking/http.py Normal file
View File

@ -0,0 +1,6 @@
okResponse = 'HTTP/1.1 200 OK\r\ncontent-type: text/html\r\n\r\n'.encode('ascii')
okTextResponse = 'HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\n\r\n'.encode('ascii')
okJsonResponse = 'HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\r\n'.encode('ascii')
okIconResponse = 'HTTP/1.1 200 OK\r\ncontent-type: image/x-icon\r\n\r\n'.encode('ascii')
notFoundResponse = 'HTTP/1.1 404 Not Found\r\n\r\n'.encode('ascii')
serverErrorResponse = 'HTTP/1.1 500 Internal Server Error\r\n\r\n'.encode('ascii')

127
networking/logging.py Normal file
View File

@ -0,0 +1,127 @@
import machine
import os
import gc
from . import util
log_file = 'log.txt'
LOG_INFO = 0b00001
LOG_WARNING = 0b00010
LOG_ERROR = 0b00100
LOG_DEBUG = 0b01000
LOG_EXCEPTION = 0b10000
LOG_ALL = LOG_INFO | LOG_WARNING | LOG_ERROR | LOG_DEBUG | LOG_EXCEPTION
_logging_types = LOG_ALL
# the log file will be truncated if it exceeds _log_truncate_at bytes in
# size. the defaults values are designed to limit the log to at most
# three blocks on the Pico
_log_truncate_at = 11 * 1024
_log_truncate_to = 8 * 1024
def file_size(file):
try:
return os.stat(file)[6]
except OSError:
return None
def set_truncate_thresholds(truncate_at, truncate_to):
global _log_truncate_at
global _log_truncate_to
_log_truncate_at = truncate_at
_log_truncate_to = truncate_to
def enable_logging_types(types):
global _logging_types
_logging_types = _logging_types | types
def disable_logging_types(types):
global _logging_types
_logging_types = _logging_types & ~types
# truncates the log file down to a target size while maintaining
# clean line breaks
def truncate(file, target_size):
# get the current size of the log file
size = file_size(file)
# calculate how many bytes we're aiming to discard
discard = size - target_size
if discard <= 0:
return
with open(file, 'rb') as infile:
with open(file + '.tmp', 'wb') as outfile:
# skip a bunch of the input file until we've discarded
# at least enough
while discard > 0:
chunk = infile.read(1024)
discard -= len(chunk)
# try to find a line break nearby to split first chunk on
break_position = max(
chunk.find(b'\n', -discard), # search forward
chunk.rfind(b'\n', -discard) # search backwards
)
if break_position != -1: # if we found a line break..
outfile.write(chunk[break_position + 1:])
# now copy the rest of the file
while True:
chunk = infile.read(1024)
if not chunk:
break
outfile.write(chunk)
# delete the old file and replace with the new
os.remove(file)
os.rename(file + '.tmp', file)
def log(level, text):
log_entry = '{0} [{1:8} /{2:>4}kB] {3}'.format(
util.datetime(),
level,
round(gc.mem_free() / 1024),
text
)
print(log_entry)
with open(log_file, 'a') as logfile:
logfile.write(log_entry + '\n')
if _log_truncate_at and file_size(log_file) > _log_truncate_at:
truncate(log_file, _log_truncate_to)
def info(*items):
if _logging_types & LOG_INFO:
log('info', ' '.join(map(str, items)))
def warn(*items):
if _logging_types & LOG_WARNING:
log('warning', ' '.join(map(str, items)))
def error(*items):
if _logging_types & LOG_ERROR:
log('error', ' '.join(map(str, items)))
def debug(*items):
if _logging_types & LOG_DEBUG:
log('debug', ' '.join(map(str, items)))
def exception(*items):
if _logging_types & LOG_EXCEPTION:
log('exception', ' '.join(map(str, items)))

41
networking/ntp.py Normal file
View File

@ -0,0 +1,41 @@
import machine
import struct
import time
import usocket
from . import logging
from . import util
def sync():
if fetch(timeout=3):
logging.info(f'time updated to {util.datetime()}')
else:
logging.error(f'failed to update time')
def fetch(synch_with_rtc=True, timeout=10):
ntp_host = 'time.cifelli.xyz'
timestamp = None
try:
query = bytearray(48)
query[0] = 0x1b
address = usocket.getaddrinfo(ntp_host, 123)[0][-1]
socket = usocket.socket(usocket.AF_INET, usocket.SOCK_DGRAM)
socket.settimeout(timeout)
socket.sendto(query, address)
data = socket.recv(48)
socket.close()
local_epoch = 2208988800
timestamp = struct.unpack("!I", data[40:44])[0] - local_epoch
timestamp = time.gmtime(timestamp)
except Exception as e:
return None
if synch_with_rtc:
machine.RTC().datetime((
timestamp[0], timestamp[1], timestamp[2], timestamp[6],
timestamp[3], timestamp[4], timestamp[5], 0))
return timestamp

99
networking/server.py Normal file
View File

@ -0,0 +1,99 @@
import io
import select
import socket
import sys
from . import logging
from . import wifi
from . import http
class Server:
def __init__(self):
self.wlan = wifi.connect()
self.socket = socket.socket()
self.poller = select.poll()
self.default_path = 'index.html'
def cleanup(self):
self.socket.close()
self.wlan.disconnect()
def run(self):
try:
addr = self.listen()
self.poller.register(self.socket, select.POLLIN)
logging.info(f'listening on {addr}')
while True:
try:
self.serve()
self.work()
except Exception as e:
self.logException(e)
finally:
self.cleanup()
def logException(self, e):
buf = io.StringIO()
sys.print_exception(e, buf)
logging.debug(f'exception:', buf.getvalue())
def listen(self):
addr = socket.getaddrinfo('0.0.0.0', 80)[0][-1]
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.socket.bind(addr)
self.socket.listen(1)
return addr
def serve(self):
evts = self.poller.poll(5000)
for sock, _evt in evts:
try:
conn, addr = sock.accept()
logging.info(f'client connected from {addr}')
request = conn.recv(1024).decode('utf-8').strip()
self.handleRequest(conn, request)
except:
conn.write(http.serverErrorResponse)
raise
finally:
conn.close()
def handleRequest(self, conn, request):
[method, path, _protocol] = request.partition('\n')[0].split()
logging.info(f'{method} {path}')
try:
if method == 'GET':
response = self.handlePath(path.strip('/'))
conn.write(self.getPathContentType(path))
conn.write(response)
else:
conn.write(http.notFoundResponse)
except OSError:
conn.write(http.notFoundResponse)
def getPathContentType(self, path):
if path.endswith('.txt'):
return http.okTextResponse
elif path.endswith('.json'):
return http.okJsonResponse
elif path.endswith('.ico'):
return http.okIconResponse
return http.okResponse
def handlePath(self, _path):
return ''
def work(self):
if not self.wlan.isconnected():
self.wlan = wifi.connect()

41
networking/templates.py Normal file
View File

@ -0,0 +1,41 @@
def render(template, **kwargs):
[startString, endString] = ['{{', '}}']
[startLength, endLength] = [len(startString), len(endString)]
with open(template) as f:
data = f.read()
tokenCaret = 0
result = ''
isRendering = True
while isRendering:
start = data.find(startString, tokenCaret)
end = data.find(endString, start)
isRendering = start != -1 and end != -1
if isRendering:
token = data[start + startLength:end].strip()
result = (
result +
data[tokenCaret:start] +
replaceToken(token, kwargs)
)
tokenCaret = end + endLength
else:
result = result + data[tokenCaret:]
return result
def replaceToken(token, values):
result = str(values[token]) if token in values else ''
result = result.replace('&', '&amp;')
result = result.replace('"', '&quot;')
result = result.replace("'", '&apos;')
result = result.replace('>', '&gt;')
result = result.replace('<', '&lt;')
return result

18
networking/util.py Normal file
View File

@ -0,0 +1,18 @@
import machine
import time
def datetime():
dt = machine.RTC().datetime()
return '{0:04d}-{1:02d}-{2:02d} {4:02d}:{5:02d}:{6:02d} UTC'.format(*dt)
def datetimeISO8601():
dt = machine.RTC().datetime()
return '{0:04d}-{1:02d}-{2:02d}T{4:02d}:{5:02d}:{6:02d}Z'.format(*dt)
def secondsElapsed(ticks1, ticks2):
return time.ticks_diff(ticks1, ticks2) / 1000

49
networking/wifi.py Normal file
View File

@ -0,0 +1,49 @@
import network
import rp2
import time
from . import logging
from .config import secrets
class WifiConnectionError(RuntimeError):
pass
def connect():
while True:
try:
return connectToWifi()
except WifiConnectionError as e:
logging.error(e.value)
time.sleep(180)
def connectToWifi():
rp2.country('US')
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.config(pm=0xa11140)
wlan.connect(secrets['ssid'], secrets['password'])
wait_for_connection(wlan)
logging.info('connected')
logging.info(f'ip = {wlan.ifconfig()[0]}')
return wlan
def wait_for_connection(wlan):
maxWait = 10
while maxWait > 0:
if wlan.status() < 0 or wlan.status() >= 3:
break
maxWait -= 1
logging.info('waiting for connection...')
time.sleep(1)
if wlan.status() != 3:
raise WifiConnectionError('network connection failed')

0
sensors/__init__.py Normal file
View File

253
sensors/mcp9808.py Normal file
View File

@ -0,0 +1,253 @@
# Imports
from machine import I2C
# Register pointers
REG_CONFIG = const(1)
REG_TEMP_BOUNDARY_UPPER = const(2)
REG_TEMP_BOUNDARY_LOWER = const(3)
REG_TEMP_BOUNDARY_CRITICAL = const(4)
REG_TEMP = const(5)
REG_MANUFACTURER_ID = const(6)
REG_DEVIDE_ID = const(7)
REG_RESOLUTION = const(8)
# Sensor resolution values
TEMP_RESOLUTION_MIN = const(0) # +0.5 C, refresh rate 30 ms
TEMP_RESOLUTION_LOW = const(1) # +0.25 C, refresh rate 65 ms
TEMP_RESOLUTION_AVG = const(2) # +0.125 C, refresh rate 130 ms
TEMP_RESOLUTION_MAX = const(3) # +0.0625 C, refresh rate 250 ms [Default]
# Alert selectors
ALERT_SELECT_ALL = const(0) # ambient > upper || ambient > critical || ambient < lower [Default]
ALERT_SELECT_CRIT = const(1) # Ambient temp > critical
# Alert polarity
ALERT_POLARITY_ALOW = const(0) # Active-low, requires pull-up [Default]
ALERT_POLARITY_AHIGH = const(1) # Active-high
# Alert output mode
ALERT_OUTPUT_COMPARATOR = const(0)
ALERT_OUTPUT_INTERRUPT = const(1)
class MCP9808(object):
"""
This class implements an interface to the MCP9808 temprature sensor from
Microchip.
"""
def __init__(self, i2c=None, addr=0x18):
"""
Initialize a sensor object on the given I2C bus and accessed by the
given address.
"""
if i2c == None or i2c.__class__ != I2C:
raise ValueError('I2C object needed as argument!')
self._i2c = i2c
self._addr = addr
self._check_device()
def _send(self, buf):
"""
Sends the given bufer object over I2C to the sensor.
"""
if not isinstance(buf, bytearray):
buf = bytearray([buf])
if hasattr(self._i2c, "writeto"):
# Micropython
self._i2c.writeto(self._addr, buf)
elif hasattr(self._i2c, "send"):
# PyBoard Micropython
self._i2c.send(self._addr, buf)
else:
raise Exception("Invalid I2C object. Unknown Micropython/platform?")
def _recv(self, n):
"""
Read bytes from the sensor using I2C. The byte count must be specified
as an argument.
Returns a bytearray containing the result.
"""
if hasattr(self._i2c, "writeto"):
# Micropython (PyCom)
return self._i2c.readfrom(self._addr, n)
elif hasattr(self._i2c, "send"):
# PyBoard Micropython
return self._i2c.recv(n, self._addr)
else:
raise Exception("Invalid I2C object. Unknown Micropython/platform?")
def _check_device(self):
"""
Tries to identify the manufacturer and device identifiers.
"""
self._send(REG_MANUFACTURER_ID)
self._m_id = self._recv(2)
if not self._m_id == b'\x00T':
raise Exception("Invalid manufacturer ID: '%s'!" % self._m_id)
self._send(REG_DEVIDE_ID)
self._d_id = self._recv(2)
if not self._d_id == b'\x04\x00':
raise Exception("Invalid device or revision ID: '%s'!" % self._d_id)
def set_shutdown_mode(self, shdn=True):
"""
Set sensor into shutdown mode to draw less than 1 uA and disable
continous temperature conversion.
"""
if shdn.__class__ != bool:
raise ValueError('Boolean argument needed to set shutdown mode!')
self._send(REG_CONFIG)
cfg = self._recv(2)
b = bytearray()
b.append(REG_CONFIG)
if shdn:
b.append(cfg[0] | 1)
else:
b.append(cfg[0] & ~1)
b.append(cfg[1])
self._send(b)
def set_alert_mode(self, enable_alert=True, output_mode=ALERT_OUTPUT_INTERRUPT, polarity=ALERT_POLARITY_ALOW, selector=ALERT_SELECT_ALL):
"""
Set sensor into alert mode with the provided output,
polarity and selector parameters
If output mode is set to interrupt, a call to acknowledge_alert_irq()
is required to deassert the MCP9808
"""
if enable_alert.__class__ != bool:
raise ValueError('Boolean argument needed to set alert mode!')
if output_mode not in [ALERT_OUTPUT_COMPARATOR, ALERT_OUTPUT_INTERRUPT]:
raise ValueError("Invalid output mode set.")
if selector not in [ALERT_SELECT_ALL, ALERT_SELECT_CRIT]:
raise ValueError("Invalid alert selector set.")
if polarity not in [ALERT_POLARITY_ALOW, ALERT_POLARITY_AHIGH]:
raise ValueError("Invalid alert polarity set.")
enable_alert = 1 if enable_alert else 0
self._send(REG_CONFIG)
cfg = self._recv(2)
alert_bits = (output_mode | (polarity << 1) | (selector << 2) | (enable_alert << 3)) & 0xF
lsb_data = (cfg[1] & 0xF0) | alert_bits
b = bytearray()
b.append(REG_CONFIG)
b.append(cfg[0])
b.append(lsb_data)
self._send(b)
def acknowledge_alert_irq(self):
"""
Must be called if MCP9808 is operating in interrupt output mode
"""
self._send(REG_CONFIG)
cfg = self._recv(2)
b = bytearray()
b.append(REG_CONFIG)
b.append(cfg[0]) # MSB data
b.append(cfg[1] | 0x20) # LSB data with interrupt clear bit set
self._send(b)
def set_alert_boundary_temp(self, boundary_register, value):
"""
Sets the alert boundary for the requested boundary register
"""
if boundary_register not in [REG_TEMP_BOUNDARY_LOWER, REG_TEMP_BOUNDARY_UPPER, REG_TEMP_BOUNDARY_CRITICAL]:
raise ValueError("Given alert boundary register is not valid!")
if value < -128 or value > 127: # 8 bit two's complement
raise ValueError("Temperature out of range [-128, 127]")
integral = int(value)
frac = abs(value - integral)
if integral < 0:
integral = (1 << 9) + integral
integral = ((integral & 0x1FF) << 4)
frac = (((1 if frac * 2 >= 1 else 0) << 1) + (1 if (frac * 2 - int(frac * 2)) * 2 >= 1 else 0)) << 2
twos_value = (integral + frac if value >= 0 else integral - frac) & 0x1ffc
b = bytearray()
b.append(boundary_register)
b.append((twos_value & 0xFF00) >> 8)
b.append(twos_value & 0xFF)
self._send(b)
def set_resolution(self, r):
"""
Sets the temperature resolution.
"""
if r not in [TEMP_RESOLUTION_MIN, TEMP_RESOLUTION_LOW, TEMP_RESOLUTION_AVG, TEMP_RESOLUTION_MAX]:
raise ValueError('Invalid temperature resolution requested!')
b = bytearray()
b.append(REG_RESOLUTION)
b.append(r)
self._send(b)
def get_temp(self):
"""
Read temperature in degree celsius and return float value.
"""
self._send(REG_TEMP)
raw = self._recv(2)
u = (raw[0] & 0x0f) << 4
l = raw[1] / 16
if raw[0] & 0x10 == 0x10:
temp = (u + l) - 256
else:
temp = u + l
return temp
def get_temp_int(self):
"""
Read a temperature in degree celsius and return a tuple of two parts.
The first part is the decimal part and the second the fractional part
of the value.
This method does avoid floating point arithmetic completely to support
platforms missing float support.
"""
self._send(REG_TEMP)
raw = self._recv(2)
u = (raw[0] & 0xf) << 4
l = raw[1] >> 4
if raw[0] & 0x10 == 0x10:
temp = (u + l) - 256
frac = -((raw[1] & 0x0f) * 100 >> 4)
else:
temp = u + l
frac = (raw[1] & 0x0f) * 100 >> 4
return temp, frac
def _debug_config(self, cfg=None):
"""
Prints the first 9 bits of the config register mapped to human
readable descriptions
"""
if not cfg:
self._send(REG_CONFIG)
cfg = self._recv(2)
# meanings[a][b] with a the bit index (LSB order),
# b=0 the config description and b={bit value}+1 the value description
meanings = [
["Alert output mode", "Comparator", "Interrupt"],
["Alert polarity", "Active-low", "Active-high"],
["Alert Selector", "All", "Only Critical"],
["Alert enabled", "False", "True"],
["Alert status", "Not asserted", "Asserted as set by mode"],
["Interrupt clear bit", "0", "1"],
["Window [low, high] locked", "Unlocked", "Locked"],
["Critical locked", "Unlocked", "Locked"],
["Shutdown", "False", "True"]
]
print("Raw config: {}".format(str(cfg)))
for i in range(0, min(len(meanings), len(cfg)*8)):
part = 0 if i > 7 else 1
value = 1 if (cfg[part] & (2**(i % 8))) > 0 else 0
print(meanings[i][0] + ": " + meanings[i][1 + value])

18
sensors/watersensor.py Normal file
View File

@ -0,0 +1,18 @@
import machine
class WaterSensor:
def __init__(self, pin):
self.waterSensor = machine.ADC(pin)
self.reading = self.waterSensor.read_u16()
self.threshold = 2000
def takeReading(self):
self.reading = self.waterSensor.read_u16()
def isWaterPresent(self):
return self.reading > self.threshold
def waterStatus(self):
return 'Present' if self.isWaterPresent() else 'Absent'

BIN
www/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

40
www/index.html Normal file
View File

@ -0,0 +1,40 @@
<html>
<head>
<title>Pico W</title>
<meta http-equiv="refresh" content="500">
<style>
body {
margin: 20px;
font-family: sans-serif;
text-align: center;
background-color: #333;
color: #ddd;
}
.led-on {
color: greenyellow;
}
.led-off {
color: lightblue;
}
.water-present {
color: pink;
}
.water-absent {
color: lightblue;
}
</style>
</head>
<body>
<h2>Pico W</h2>
<p>{{datetime}}</p>
<p>Led is <span class="{{ledClass}}">{{ledStatus}}</span></p>
</body>
</html>