Skip to main content

Software

Assets

Logs Dump contains .csv and .log that is generated by the previous implementation

Legacy Setup

git clone https://github.com/usdevs/laundry-pi.git

Install dependencies:

pip install firebase-admin Adafruit-Blinka Adafruit-ADS1x15

References

Setup device specific configuration in config.py

config.py stores parameters needed for database (firestore) access and device specific configuration.

  1. Generate a firestore cert, and save it somewhere on the RPI

    • This involves creating a firebase service account, and generating a cert for it. (reference)
  2. Copy config.py.example as config.py and fill in it in appropriately. FIRESTORE_CERT refers to the path to the firestore cert file we generated above.

Scheduling

  • Start main.py when the RPI boots up

    crontab -e @reboot cd <path-to-laundro-src> && python3 runner.py main.py main`
  • Check upstream repo for new updates at a interval of 1 minute

    crontab -e * * * * * cd path/to/laundro && git pull && git log --pretty=oneline -1 | python3 flagger.py

How it Works

main.py

This is where the main control loop resides and the entrypoint of the program.

info

Also provides a control loop (local_main) that updates pin values (to a .csv) without connection to firebase. This is called when an error is thrown when triggering the actual control loop.

def main():
# Initiates a logger
init_logger(config.LOGDIR)
log = logging.getLogger()
log.info('main script started.')

# Set up Firestore connection
firestore = FirestoreManager(config.FIRESTORE_CERT, config.PI_ID)

# Get the list of pins
# Only grabs the pins that are allocated to config.PI_ID (more on this below)
pins = get_pins(config.PI_ID)
flag = flagger.Flag(flagger.flag)

# Creates firestore documents for the pins and rpi, if it doesn't exist already
firestore.init_pins(map(lambda p: p.id, pins))
firestore.init_pi()

# Grab last recorded pin readings from firestore
prev_on = {} # { <pin_id: int> : is_on: bool }
washer_ids = firestore.get_washing_machine_pin_ids()
for p in pins:
on = p.is_on()
prev_on[p.id] = on

# For each pin, get the previous time when it was updated from firebase
current = firestore.get_pin_data(p.id)

now = sg_time_now()
timediff = now - current['timeChanged']

'''
If the the status of the pin is unchanged for a period under 30 min (for washers) / 45 min (for dryers) we not modify the pin record

Intuition:
- To update timeChanged of the pin status upon script start
- However since we are not certain of what is the exact timestamp this change happen, timeChangedCertain = False

Possible Concern:
- Can we actually trust the clocks of the RPI?
'''
if on == current['on'] and p.id in washer_ids and timediff <= dt.timedelta(minutes=30) or \
timediff <= dt.timedelta(minutes=45):
continue
else:
firestore.update_pin(p.id, on, sg_time_now(), timeChangedCertain = False)

'''
Assumes that each run of the loop takes around 25 seconds

Observation:
Runtime of one iteration ~= 2secs * 9pins + 1 secs sleep (around 21-22 secs)

2secs * 9pins : More on this in `pin.py`
'''
seconds = 0
while True:
# Every 6 minutes (24 runs), update the `lastSeen` time for the RPi to the current time.
if seconds == 600:
firestore.update_pi_last_seen()
seconds = 0

# Check if any pins have changed
for p in pins:
on = p.is_on()
if on != prev_on[p.id]:
firestore.update_pin(p.id, on, sg_time_now())
prev_on[p.id] = on
seconds = 0

# Check for updates from Github
if flag.flagged():
flag.unflag()
log.info('changes from github were detected. restarting main script.')
break

time.sleep(1) # I don't know why we are sleeping for 1 second, but it is there I guess
seconds += 25
log.debug("{} seconds".format(seconds))

get_pins(pi_id: int): List[Pin]: Returns an array of Pins associated with each RPI

If pi_id == 1, returns

ClassPin idADC IDADC Pin idrecord_valuesthreshold
Pin1ads1ADS.P0True32000 (Default)
Pin2ads1ADS.P1True32000 (Default)
Pin3ads1ADS.P2True32000 (Default)
Pin4ads1ADS.P3True32000 (Default)
Pin5ads2ADS.P0True20000
Pin6ads2ADS.P1True32000 (Default)
Pin7ads2ADS.P2True32000 (Default)
Pin8ads2ADS.P3True32000 (Default)
Pin9ads3ADS.P0True25000

If pi_id == 2, returns

ClassPin idADC IDADC Pin idrecord_valuesthreshold
Pin10ads1ADS.P0True15000
Pin11ads1ADS.P1True32000 (Default)
Pin12ads1ADS.P2True32000 (Default)
Pin13ads1ADS.P3True21000
Pin14ads2ADS.P0True32000 (Default)
Pin15ads2ADS.P1True32000 (Default)
Pin16ads2ADS.P2True32000 (Default)
Pin17ads2ADS.P3True32000 (Default)
Pin18ads3ADS.P0True32000 (Default)

Otherwise, raises an error

info

As their name implies, ADS.P0, ADS.P1, ADS.P2 and ADS.P3 corresponds to the pin values 0, 1, 2, and 3 respectively, and are int values 0, 1, 2, and 3.

note

ads1, ads2 and ads3 are ADS ids hardcoded as:

ads1 = ADS.ADS1115(i2c, address=0x48)
ads2 = ADS.ADS1115(i2c, address=0x49)
ads3 = ADS.ADS1115(i2c, address=0x4a)

runner.py (TODO)

Importantly, runner.py takes a module path and function and runs function in a process. Upon the process terminating, module path is reloaded and the new function is run in a new process. This is used to run main.py

flagger.py (TODO)

Previous repo says that it is a small ecosystem to update the code on the pi every time a new comit is made.


*.log

main.py writes logs with the logging module to the directory ~/laundro_logs/<pi ID>/.

A log file can be read with live updates using:

tail -f <name of log file>
Log FileDescription
all.logContains all log messages
info.logContains only INFO level and above messages

Practically speaking, log messages for individual sensor readings will be in all.log and not info.log

sample *.log files can be find in the logs dump here

firestore_manager.py (TODO)

pin.py

Contains the class definition for Pin.

Each Pin object represents a input pin of a ADC/ADS, and has the following properties and methods:

PropertiesInfo
id (int) Unique ID to every pin (should be unique across RPis too)
adc (adafruit_ads1x15.ads1115.ADS.ADS1115)Represents one ADC module. (ADC Module that the pin connects to)
adc_pin (adafruit_ads1x15.ads1115.ADS.P0/1/2/3)Represents a pin on the ADC module. (ADC pin that pin connects to)
threshold (int, optional)Light threshold. The pin is on if the light value is below this threshold. Default = 32000
record_values (boolean)Whether to record light value readings. Readings are recorded in a csv file named pin-<pin ID>.csv.
(Instance) MethodsInfoReturns
is_on_single(self)Checks if this pin is currently on, based on 1 reading at the moment. This may return off if the pin is blinking.tuple(bool, int)
is_on(self)True if this pin is on or blinking, False otherwise. The pin will appear off if it is disconnected.bool

To test whether the pin is on (detecting light), an analog pin value is read via

light_value = adafruit_ads1x15.analog_in.AnalogIn(adc, adc_pin).value

and the light is considered on if light_value < self.threshold.

is_on(self)

Core function that helps determine whether the washer/dryer is on or off

def is_on(self):
"""
Checks whether this pin is on, which includes blinking. Records light values to
a csv file, if self.record_values is True.
Returns:
True if this pin is on or blinking, False otherwise.
The pin will appear off if it is disconnected.
"""
log = logging.getLogger()
values = []
final_on = False

'''
Takes 20 readings over 2 seconds (every 0.1 seconds) and returns `True` if at least one detects the pin as on.

This is part where they account for blinking
'''
for i in range(20):
on, val = self.is_on_single()
values.append(val)
if not final_on and on:
final_on = True
time.sleep(0.1)

log.debug("{} is off. Values:{}".format(self, values))

# Logs light values into specified .csv file
if self.record_values:
with open('pin-' + str(self.id) + '.csv','a+') as f:
writer = csv.writer(f,quoting=csv.QUOTE_NONNUMERIC)
now = dt.datetime.now().isoformat()
rows = map(lambda v: (now,v), values)
writer.writerows(rows)

return final_on

sample *.csv files can be find in the logs dump here

config.py

Stores some config values:

NameDescription
FIRESTORE_CERTThe path to the JSON file containing the Firestore certificate. Used to create a new FirestoreManager
PI_IDValue is either 1 or 2, is an identifier that determines which pins to update. Is called by get_pins(id) in main.py for a list of Pin objects.
LOGDIRDirectory path to save log files in. Called in utils.init_logger(logdir="", logger_name=None)