Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Run Server (Virtual)",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/server.py",
"console": "integratedTerminal",
"args": ["--virtual"]
}
]
}
2 changes: 1 addition & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Returns a JSON with all sprinklers as an array of a map
'group' : <int: Not used yet>,
'schedule' : {
'duration' : <int: minutes>,
'cycles' : <int: cycles it should run, minium 1>,
'cycles' : <int: cycles it should run, minimum 1>,
'days' : <int: days it should run>,
'shift' : <int: shift start day>
}
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ may start the program at 1.35am to reach that goal (based on your schedules).

# installation

## Install Raspbian Buster Lite
## Install Raspberry Pi OS Lite

- Go to [raspberrypi.org](https://www.raspberrypi.org/) and download their [latest image](https://downloads.raspberrypi.org/raspbian_lite_latest)
- Write the image onto your SD card using [Etcher](https://www.balena.io/etcher/)
- Go to [raspberrypi.com](https://www.raspberrypi.com/software/) and use the Raspberry Pi Imager, or download their [latest image](https://downloads.raspberrypi.org/raspios_lite_armhf_latest)
- Write the image onto your SD card using the Raspberry Pi Imager or [Etcher](https://www.balena.io/etcher/)

## Setup your environment

Expand Down
189 changes: 155 additions & 34 deletions html/index.html

Large diffs are not rendered by default.

89 changes: 65 additions & 24 deletions html/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with rainfall. If not, see <http://www.gnu.org/licenses/>.
*/
var currentView = localStorage.getItem('viewMode') || 'desktop';

function loadSettings()
{
$.ajax({
Expand All @@ -37,19 +39,17 @@ function loadSettings()
function updateBackend(thiz, value)
{
j = jQuery(thiz);
jj = j.parent();
s = jj.data('sprinkler')
if (s == null) {
jj = jj.parent();
s = jj.data('sprinkler')
if (s == null) {
jj = jj.parent();
s = jj.data('sprinkler')
}
}
jj = j.closest('.sprinkler-entry');
s = jj.data('sprinkler');
i = thiz.id;
v = (value == null ? j.val() : value); // Allow override

if (v === "") {
alert("Value cannot be empty.");
setSprinkler(s, jj); // Revert the UI to the previous valid state
return;
}

if (!isNaN(parseInt(v)))
v = parseInt(v);

Expand Down Expand Up @@ -86,7 +86,7 @@ function updateBackend(thiz, value)
function toggleEnable(thiz)
{
j = jQuery(thiz);
s = j.parent().parent().data('sprinkler')
s = j.closest('.sprinkler-entry').data('sprinkler');
if (s.enabled) {
j.removeClass('btn-success').addClass('btn-secondary');
updateBackend(thiz, false);
Expand All @@ -98,14 +98,14 @@ function toggleEnable(thiz)

function showPIN(thiz)
{
alert("PIN used by this valve: " + jQuery(thiz).parent().parent().data('sprinkler').pin );
alert("PIN used by this valve: " + jQuery(thiz).closest('.sprinkler-entry').data('sprinkler').pin );
}

function deleteStation(thiz)
{
s = jQuery(thiz).parent().parent().data('sprinkler');
s = jQuery(thiz).closest('.sprinkler-entry').data('sprinkler');
if (confirm('Are you sure you wish to delete "' + s.name + '" (PIN ' + s.pin + ')')) {
jQuery(thiz).parent().parent().remove();
jQuery(thiz).closest('.sprinkler-entry').remove();
$.ajax({
type:"POST",
contentType: "application/json; charset=utf-8",
Expand All @@ -120,31 +120,42 @@ function deleteStation(thiz)
}
}

function getPlayIcon() {
var size = currentView === 'mobile' ? '1.5em' : '1em';
return '<svg class="bi bi-play" width="' + size + '" height="' + size + '" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.804 8L5 4.633v6.734L10.804 8zm.792-.696a.802.802 0 010 1.392l-6.363 3.692C4.713 12.69 4 12.345 4 11.692V4.308c0-.653.713-.998 1.233-.696l6.363 3.692z" clip-rule="evenodd"/></svg>';
}

function getStopIcon() {
var size = currentView === 'mobile' ? '1.5em' : '1em';
return '<svg class="bi bi-stop" width="' + size + '" height="' + size + '" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3.5 5A1.5 1.5 0 015 3.5h6A1.5 1.5 0 0112.5 5v6a1.5 1.5 0 01-1.5 1.5H5A1.5 1.5 0 013.5 11V5zM5 4.5a.5.5 0 00-.5.5v6a.5.5 0 00.5.5h6a.5.5 0 00.5-.5V5a.5.5 0 00-.5-.5H5z" clip-rule="evenodd"/></svg>';
}

function toggleManual(thiz)
{
j = jQuery(thiz);
s = j.parent().parent().data('sprinkler');
s = j.closest('.sprinkler-entry').data('sprinkler');
if (s.open) {
j.removeClass('btn-success').addClass('btn-primary').html('<svg class="bi bi-play" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.804 8L5 4.633v6.734L10.804 8zm.792-.696a.802.802 0 010 1.392l-6.363 3.692C4.713 12.69 4 12.345 4 11.692V4.308c0-.653.713-.998 1.233-.696l6.363 3.692z" clip-rule="evenodd"/></svg>');
j.removeClass('btn-success').addClass('btn-primary').html(getPlayIcon());
updateBackend(thiz, false);
} else {
// First, remove any other ones
$('#sprinklers #open').each(function() {
$(this).removeClass('btn-success').addClass('btn-primary').html('<svg class="bi bi-play" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.804 8L5 4.633v6.734L10.804 8zm.792-.696a.802.802 0 010 1.392l-6.363 3.692C4.713 12.69 4 12.345 4 11.692V4.308c0-.653.713-.998 1.233-.696l6.363 3.692z" clip-rule="evenodd"/></svg>');
$(this).parent().parent().data('sprinkler').open = false;
$(this).removeClass('btn-success').addClass('btn-primary').html(getPlayIcon());
$(this).closest('.sprinkler-entry').data('sprinkler').open = false;
});
j.removeClass('btn-primary').addClass('btn-success').html('<svg class="bi bi-stop" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3.5 5A1.5 1.5 0 015 3.5h6A1.5 1.5 0 0112.5 5v6a1.5 1.5 0 01-1.5 1.5H5A1.5 1.5 0 013.5 11V5zM5 4.5a.5.5 0 00-.5.5v6a.5.5 0 00.5.5h6a.5.5 0 00.5-.5V5a.5.5 0 00-.5-.5H5z" clip-rule="evenodd"/></svg>');
j.removeClass('btn-primary').addClass('btn-success').html(getStopIcon());
updateBackend(thiz, true);
}
}

function addSprinkler(sprinkler)
{
var e = $('#template').clone().removeAttr('id').removeClass('d-none').attr('id', 'entry');
var templateId = currentView === 'mobile' ? '#template-mobile' : '#template-desktop';
var e = $(templateId).clone().removeAttr('id').removeClass('d-none').attr('id', 'entry');
setSprinkler(sprinkler, e);

if (sprinkler.open) {
e.find('#open').removeClass('btn-primary').addClass('btn-success').html('<svg class="bi bi-stop" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3.5 5A1.5 1.5 0 015 3.5h6A1.5 1.5 0 0112.5 5v6a1.5 1.5 0 01-1.5 1.5H5A1.5 1.5 0 013.5 11V5zM5 4.5a.5.5 0 00-.5.5v6a.5.5 0 00.5.5h6a.5.5 0 00.5-.5V5a.5.5 0 00-.5-.5H5z" clip-rule="evenodd"/></svg>');
e.find('#open').removeClass('btn-primary').addClass('btn-success').html(getStopIcon());
}

e.find('#gpio').text(sprinkler.pin);
Expand Down Expand Up @@ -187,11 +198,17 @@ function updateProgramStatus() {
}).done(function(e, data) {
if (e.running)
$('#programRunning').modal('show')

var sprinklersList = "";
if (e.next_sprinklers && e.next_sprinklers.length > 0) {
sprinklersList = "<br><strong>Sprinklers running next:</strong> " + e.next_sprinklers.join(", ");
}

// Update the runtime info
if (e.duration > 0)
$('#next-run').text('Next scheduled run happens at ' + time2str(e.start, false) + ' (runtime of ' + time2str(e.duration, true) + ')');
$('#next-run').html('Next scheduled run happens at ' + time2str(e.start, false) + ' (runtime of ' + time2str(e.duration, true) + ')' + sprinklersList);
else
$('#next-run').text('No scheduled sprinklers today, next run in ' + e.next + ' day' + (e.next > 1 ? 's' : ''));
$('#next-run').html('No scheduled sprinklers today, next run in ' + e.next + ' day' + (e.next > 1 ? 's' : '') + sprinklersList);
}).fail(function(e, data) {
alert('Unable to load program state, please reload');
});
Expand All @@ -207,7 +224,32 @@ function time2str(m, units) {
return h + ':' + m;
}

function updateViewToggleText() {
if (currentView === 'mobile') {
$('#toggleView').text('Switch to Desktop View');
} else {
$('#toggleView').text('Switch to Mobile View');
}
}

function setup() {
updateViewToggleText();
$('#toggleView').click(function(e) {
e.preventDefault();
currentView = (currentView === 'desktop') ? 'mobile' : 'desktop';
localStorage.setItem('viewMode', currentView);
updateViewToggleText();

// Re-fetch and redraw all sprinklers with the newly selected template
$('#sprinklers').empty();
$.ajax({
url:"/sprinklers",
type:"GET",
}).done(function(resp){
for (s in resp) addSprinkler(resp[s]);
});
});

$('#add').click(function() {
name = $('#add_name').val().trim()
pin = $('#add_pin').val().trim();
Expand Down Expand Up @@ -342,4 +384,3 @@ function setup() {
}
});
}

23 changes: 20 additions & 3 deletions modules/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,32 @@ def __init__(self, sprinklers, scaling, timeScale=60):
self.work = []
self.duration = 0
self.daysBeforeNextRun = 9999
self.nextRunSprinklers = []
for sprinkler in sprinklers:
if not sprinkler.enabled:
continue
# Make sure scaling is applied appropiately
sprinkler.schedule.setScaling(scaling)
calcDay = sprinkler.schedule.getCalculatedDays()
daysBeforeNextRun = calcDay - ((self._getDayCounter() + sprinkler.schedule.shift) % calcDay)
daysBeforeNextRun = calcDay - ((self._getDayCounter() - sprinkler.schedule.shift) % calcDay)
if daysBeforeNextRun == calcDay:
daysBeforeNextRun = 0
self.daysBeforeNextRun = min(self.daysBeforeNextRun, daysBeforeNextRun)

if daysBeforeNextRun < self.daysBeforeNextRun:
self.daysBeforeNextRun = daysBeforeNextRun
self.nextRunSprinklers = [sprinkler.name]
elif daysBeforeNextRun == self.daysBeforeNextRun:
self.nextRunSprinklers.append(sprinkler.name)

if daysBeforeNextRun != 0:
logging.debug('Skipping %s due to days=%d x %d, shift=%d setting (next run in %d days)', sprinkler.name, sprinkler.schedule.days, sprinkler.schedule.scaling, sprinkler.schedule.shift, daysBeforeNextRun)
logging.debug(
'Skipping %s due to days=%d x %d, shift=%d setting (next run in %d days)',
sprinkler.name,
sprinkler.schedule.days,
sprinkler.schedule.scaling,
sprinkler.schedule.shift,
daysBeforeNextRun
)
continue

work = {
Expand All @@ -61,6 +75,9 @@ def __init__(self, sprinklers, scaling, timeScale=60):
def getDaysBeforeNextRun(self):
return self.daysBeforeNextRun

def getNextRunSprinklers(self):
return self.nextRunSprinklers

def getEstimatedDuration(self):
return self.duration

Expand Down
3 changes: 3 additions & 0 deletions modules/rainfall.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ def getProgramDuration(self):
def getProgramDaysUntilNext(self):
return self.program.getDaysBeforeNextRun()

def getProgramNextRunSprinklers(self):
return self.program.getNextRunSprinklers()

def start(self):
self.quit = False
Thread.start(self)
Expand Down
8 changes: 8 additions & 0 deletions modules/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,26 @@ def __init__(self, duration=1, cycles=1, days=1, shiftDay=0, scaling=1):
self.setScaling(scaling)

def setDuration(self, duration):
try:
duration = int(duration)
except (ValueError, TypeError):
duration = getattr(self, 'duration', 1)
self.duration = 1 if duration < 1 else duration

def setCycles(self, cycles):
cycles = int(cycles)
self.cycles = 1 if cycles < 1 else cycles

def setDays(self, days):
days = int(days)
self.days = 1 if days < 1 else days

def setShift(self, shiftDay):
shiftDay = int(shiftDay)
self.shift = 0 if shiftDay < 0 else shiftDay

def setScaling(self, scaling):
scaling = int(scaling)
self.scaling = 1 if scaling < 1 else scaling

def getCalculatedDays(self):
Expand Down
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Flask
Flask-HTTPAuth
gpiozero
3 changes: 2 additions & 1 deletion server.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,8 @@ def program():
'running' : rf.programRunning,
'start' : rf.getProgramStart(),
'duration' : rf.getProgramDuration(),
'next' : rf.getProgramDaysUntilNext()
'next' : rf.getProgramDaysUntilNext(),
'next_sprinklers' : rf.getProgramNextRunSprinklers()
})
else:
if 'stop' in request.json:
Expand Down
2 changes: 1 addition & 1 deletion test.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@
tst.on()
#sleep(3)
input('Press enter to test next one...')
tst.off
tst.off()
78 changes: 78 additions & 0 deletions test_program_scheduling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import unittest
from unittest.mock import MagicMock, patch
from modules.program import program

class TestProgramScheduling(unittest.TestCase):
def create_mock_sprinkler(self, name, days, shift=0, duration=1, cycles=1, enabled=True):
s = MagicMock()
s.enabled = enabled
s.name = name
s.schedule.duration = duration
s.schedule.cycles = cycles
s.schedule.days = days
s.schedule.shift = shift
s.schedule.getCalculatedDays.return_value = days
return s

@patch('modules.program.program._getDayCounter')
def test_sprinkler_scheduling(self, mock_day_counter):
# Create sprinklers for this test
s1 = self.create_mock_sprinkler("Sprinkler 1", days=1)
s2 = self.create_mock_sprinkler("Sprinkler 2", days=2)
s3 = self.create_mock_sprinkler("Sprinkler 3", days=3)

# We will use all 3 sprinklers for this test
sprinklers = [s1, s2, s3]

# Test Run 1: Day 120 (A multiple of 1, 2, and 3 - so all align)
mock_day_counter.return_value = 120
prog_run_1 = program(sprinklers, scaling=1)
self.assertEqual(prog_run_1.getDaysBeforeNextRun(), 0)
self.assertIn("Sprinkler 1", prog_run_1.getNextRunSprinklers())
self.assertIn("Sprinkler 2", prog_run_1.getNextRunSprinklers())
self.assertIn("Sprinkler 3", prog_run_1.getNextRunSprinklers())

# Test Run 2: The following day (Day 121 - Only Sprinkler 1 triggers)
mock_day_counter.return_value = 121
prog_run_2 = program(sprinklers, scaling=1)
self.assertIn("Sprinkler 1", prog_run_2.getNextRunSprinklers())
self.assertNotIn("Sprinkler 2", prog_run_2.getNextRunSprinklers())
self.assertNotIn("Sprinkler 3", prog_run_2.getNextRunSprinklers())

# Test Run 3: The next day (Day 122 - Sprinklers 1 and 2 trigger)
mock_day_counter.return_value = 122
prog_run_3 = program(sprinklers, scaling=1)
self.assertIn("Sprinkler 1", prog_run_3.getNextRunSprinklers())
self.assertIn("Sprinkler 2", prog_run_3.getNextRunSprinklers())
self.assertNotIn("Sprinkler 3", prog_run_3.getNextRunSprinklers())

# Test Run 4: Fast forward to next full sync (Day 126 - LCM of 1, 2, and 3 is 6 days)
mock_day_counter.return_value = 126
prog_run_4 = program(sprinklers, scaling=1)
self.assertIn("Sprinkler 1", prog_run_4.getNextRunSprinklers())
self.assertIn("Sprinkler 2", prog_run_4.getNextRunSprinklers())
self.assertIn("Sprinkler 3", prog_run_4.getNextRunSprinklers())

@patch('modules.program.program._getDayCounter')
def test_shift_scheduling(self, mock_day_counter):
# Note: We use days=2 (every other day) so the schedules can alternate.
# If days=1 (every day), a shift of 1 still results in the sprinkler running every day!
s1 = self.create_mock_sprinkler("Sprinkler 1", days=2, shift=1)
s2 = self.create_mock_sprinkler("Sprinkler 2", days=2, shift=0)

sprinklers = [s1, s2]

# Test Run 1: Even day (Day 100) - Sprinkler 2 runs, Sprinkler 1 is shifted to tomorrow
mock_day_counter.return_value = 100
prog_run_1 = program(sprinklers, scaling=1)
self.assertNotIn("Sprinkler 1", prog_run_1.getNextRunSprinklers())
self.assertIn("Sprinkler 2", prog_run_1.getNextRunSprinklers())

# Test Run 2: Odd day (Day 101) - Sprinkler 1 runs, Sprinkler 2 waits for tomorrow
mock_day_counter.return_value = 101
prog_run_2 = program(sprinklers, scaling=1)
self.assertIn("Sprinkler 1", prog_run_2.getNextRunSprinklers())
self.assertNotIn("Sprinkler 2", prog_run_2.getNextRunSprinklers())

if __name__ == '__main__':
unittest.main()