From 7af5657300dd4ec6e52fe21f66e237f462e86ac7 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Thu, 13 Feb 2025 16:55:15 -0500 Subject: [PATCH 01/38] Add StringConfigPoint --- software/firmware/configuration.py | 40 +++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/software/firmware/configuration.py b/software/firmware/configuration.py index f18bb047d..7c829c33b 100644 --- a/software/firmware/configuration.py +++ b/software/firmware/configuration.py @@ -59,6 +59,25 @@ def validate(self, value) -> Validation: raise NotImplementedError +class StringConfigPoint(ConfigPoint): + """A `ConfigPoint` that allows the input of arbitrary strings + + :param name: The name of this `ConfigPoint`, will be used by scripts to lookup the configured value. + :param type: The name of this ConfigPoint's type + :param default: The default value + :param danger: If true, mark this option as dangerous to modify in the config editor + """ + def __init__( + self, name: str, default: str, danger: bool = False + ): + super().__init__(name=name, type=float, default=default, danger=danger) + + def validate(self, value) -> Validation: + """Validates the given value with this ConfigPoint. Returns a `Validation` containing the + validation result, as well as an error message containing the reason for a validation failure. + """ + return type(value) is str + class FloatConfigPoint(ConfigPoint): """A `ConfigPoint` that requires the selection from a range of floats. The default value must lie within the specified range @@ -138,7 +157,7 @@ class ChoiceConfigPoint(ConfigPoint): :param danger: If true, mark this option as dangerous to modify in the config editor """ - def __init__(self, name: str, choices: "List", default, danger: bool = False): + def __init__(self, name: str, choices: list, default, danger: bool = False): if default not in choices: raise ValueError("default value must be available in given choices") super().__init__(name=name, type="choice", default=default, danger=danger) @@ -175,7 +194,7 @@ def boolean(name: str, default: bool, danger: bool = False) -> BooleanConfigPoin return BooleanConfigPoint(name=name, default=default, danger=danger) -def choice(name: str, choices: "List", default, danger: bool = False) -> ChoiceConfigPoint: +def choice(name: str, choices: list, default, danger: bool = False) -> ChoiceConfigPoint: """A helper function to simplify the creation of ChoiceConfigPoints. Requires selection from a limited number of choices. The default value must exist in the given choices. @@ -220,6 +239,19 @@ def integer( name=name, minimum=minimum, maximum=maximum, default=default, danger=danger ) +def string( + name: str, default: str, danger: bool = False +) -> StringConfigPoint: + """A helper function to simplify the creation of StringConfigPoints. Allows the input of + any arbitrary string + + :param name: The name of this `ConfigPoint`, will be used by scripts to lookup the configured value. + :param default: The default value + :param danger: If true, mark this option as dangerous to modify in the config editor + """ + return StringConfigPoint( + name=name, default=default, danger=danger + ) class ConfigSpec: """ @@ -227,7 +259,7 @@ class ConfigSpec: script. """ - def __init__(self, config_points: "List[ConfigPoint]") -> None: + def __init__(self, config_points: list[ConfigPoint]) -> None: self.points = {} for point in config_points: if point.name in self.points: @@ -240,7 +272,7 @@ def __len__(self): def __iter__(self): return iter(self.points.values()) - def default_config(self) -> "dict(str, any)": + def default_config(self) -> dict[str, any]: """Returns the default configuration for this spec.""" return {point.name: point.default for point in self.points.values()} From 3bfdb87c6e808dc3665f79b3468ba5aca99c4f57 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Thu, 13 Feb 2025 16:55:51 -0500 Subject: [PATCH 02/38] Add initial wifi connection implementation --- software/firmware/europi.py | 14 ++++- software/firmware/experimental/wifi.py | 75 ++++++++++++++++++++++++++ software/tests/mocks/mpython.py | 27 ++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 software/firmware/experimental/wifi.py create mode 100644 software/tests/mocks/mpython.py diff --git a/software/firmware/europi.py b/software/firmware/europi.py index 710bb93ec..0be94e9c1 100644 --- a/software/firmware/europi.py +++ b/software/firmware/europi.py @@ -42,10 +42,11 @@ from configuration import ConfigSettings from framebuf import FrameBuffer, MONO_HLSB -from europi_config import load_europi_config, CPU_FREQS +from europi_config import load_europi_config, CPU_FREQS, MODEL_PICO_2W, MODEL_PICO_W from europi_display import Display, DummyDisplay from experimental.experimental_config import load_experimental_config +from experimental.wifi import WifiConnection, WifiError if sys.implementation.name == "micropython": @@ -716,5 +717,16 @@ def value(self): # e.g. to lower power consumption on a very power-constrained system freq(CPU_FREQS[europi_config.PICO_MODEL][europi_config.CPU_FREQ]) +# Connect to wifi, if supported +if europi_config.PICO_MODEL == MODEL_PICO_W or europi_config.PICO_MODEL == MODEL_PICO_2W: + try: + wifi_connection = WifiConnection() + except WifiError as err: + print(err) + wifi_connection = None +else: + wifi_connection = None + + # Reset the module state upon import. reset_state() diff --git a/software/firmware/experimental/wifi.py b/software/firmware/experimental/wifi.py new file mode 100644 index 000000000..08407a552 --- /dev/null +++ b/software/firmware/experimental/wifi.py @@ -0,0 +1,75 @@ +# Copyright 2025 Allen Synthesis +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Enables managing the wireless connection on the Pico W and Pico 2 W + +Wifi credentials are saved via experimental_config +""" +from europi_config import ( + load_europi_config, + MODEL_PICO_W, + MODEL_PICO_2W, +) +from experimental.experimental_config import * +from mpython import wifi + + +class WifiError(Exception): + """Custom exception for wifi related errors""" + + def __init__(self, message): + super().__init__(message) + + +class WifiConnection: + """ + Class to manage the wifi connection + + Raises a WifiError if the model doesn't support wifi + """ + def __init__(self): + eu_cfg = load_europi_config() + ex_cfg = load_experimental_config() + + if eu_cfg.PICO_MODEL != MODEL_PICO_2W and eu_cfg.PICO_MODEL != MODEL_PICO_W: + raise WifiError(f"Hardware {eu_cfg.PICO_MODEL} doesn't support wifi") + + self.ssid = ex_cfg.WIFI_SSID + self.password = ex_cfg.WIFI_PASSWORD + self.wifi = wifi() + + if ex_cfg.WIFI_MODE == WIFI_MODE_AP: + self.enable_ap() + else: + self.connect() + + def connect(self): + try: + self.wifi = wifi() + wifi.connectWiFi( + self.ssid, + self.password, + timeout=30 + ) + except Exception as err: + raise WifiError(f"Failed to connect to network: {err}") + + def disconnect(self): + self.wifi.disconnectWiFi() + + def enable_ap(self): + self.wifi.enable_APWiFi(self.ssid, self.password, timeout=30) + + def disable_ap(self): + self.wifi.disable_APWiFi() \ No newline at end of file diff --git a/software/tests/mocks/mpython.py b/software/tests/mocks/mpython.py new file mode 100644 index 000000000..c62a2cb37 --- /dev/null +++ b/software/tests/mocks/mpython.py @@ -0,0 +1,27 @@ +# Copyright 2025 Allen Synthesis +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +class wifi: + def __init__(self): + pass + + def connectWiFi(self, ssid, passwd, timeout=10): + pass + def disconnectWiFi(self): + pass + + def enable_APWiFi(self, essid, password=b'',channel=10): + pass + + def disable_APWiFi(self): + pass \ No newline at end of file From 7f5e7701416211cfeaba413a21e2e2e538b01e9d Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Thu, 13 Feb 2025 16:56:11 -0500 Subject: [PATCH 03/38] Add wifi, NTP configurations --- .../experimental/experimental_config.py | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/software/firmware/experimental/experimental_config.py b/software/firmware/experimental/experimental_config.py index 17621b48f..e0f2cccfc 100644 --- a/software/firmware/experimental/experimental_config.py +++ b/software/firmware/experimental/experimental_config.py @@ -30,6 +30,11 @@ RTC_NONE = "" RTC_DS3231 = "ds3231" RTC_DS1307 = "ds1307" +RTC_NTP = "ntp" + +# WIFI modes +WIFI_MODE_AP = "access_point" +WIFI_MODE_CLIENT = "client" class ExperimentalConfig: @@ -52,7 +57,7 @@ def config_points(cls): # Normally this is intended for Eurorack compatibility, but being open-source someone may # want to use it in an ecosystem that uses different specs configuration.choice( - name="VOLTS_PER_OCTAVE", + "VOLTS_PER_OCTAVE", choices=[MOOG_VOLTS_PER_OCTAVE, BUCHLA_VOLTS_PER_OCTAVE], default=MOOG_VOLTS_PER_OCTAVE, ), @@ -60,7 +65,7 @@ def config_points(cls): # RTC implementation # by default there is no RTC configuration.choice( - name="RTC_IMPLEMENTATION", + "RTC_IMPLEMENTATION", choices=[ RTC_NONE, RTC_DS3231, @@ -68,6 +73,10 @@ def config_points(cls): ], default=RTC_NONE, ), + configuration.string( + "NTP_SERVER", + default="0.pool.ntp.org", + ), # RTC Timezone offset for local time configuration.integer( @@ -82,6 +91,32 @@ def config_points(cls): maximum=59, default=0, ), + + # Wifi connection options + # only applicable with Pico W, Pico 2 W + configuration.choice( + "WIFI_MODE", + choices=[ + WIFI_MODE_CLIENT, + WIFI_MODE_AP + ], + default=WIFI_MODE_AP, + ), + configuration.string( + "WIFI_SSID", + default="", + ), + configuration.string( + "WIFI_PASSWORD", + default="", + ), + configuration.integer( + "WIFI_CHANNEL", + minimum=1, + maximum=13, + default=10, + ), + ] # fmt: on From 938485aef5cf69dba6edf13e2baae5f140dd6574 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Thu, 13 Feb 2025 16:56:29 -0500 Subject: [PATCH 04/38] Update configuration --- software/CONFIGURATION.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/software/CONFIGURATION.md b/software/CONFIGURATION.md index 010e3563c..476039a25 100644 --- a/software/CONFIGURATION.md +++ b/software/CONFIGURATION.md @@ -76,17 +76,7 @@ set the `PICO_MODEL` setting to `"pico2"`. Other configuration properties are used by [experimental features](/software/firmware/experimental/__init__.py) and can be set using a similar static configuration file. This file is located at `/config/ExperimentalConfig.json` -on the Raspberry Pi Pico. If this file does not exist, default settings will be loaded. The following -shows the default configuration: - -```json -{ - "VOLTS_PER_OCTAVE": 1.0, - "RTC_IMPLEMENTATION": "", - "UTC_OFFSET_HOURS": 0, - "UTC_OFFSET_MINUTES": 0, -} -``` +on the Raspberry Pi Pico. If this file does not exist, default settings will be loaded. Quantization options: - `VOLTS_PER_OCTAVE` must be one of `1.0` (Eurorack standard) or `1.2` (Buchla standard). Default: `1.0` @@ -96,11 +86,24 @@ RTC options: - `""`: there is no RTC present. (default) - `"ds3231"`: use a DS3231 module connected to the external I2C interface - `"ds1307"`: use a DS1307 module connected to the external I2C interface (THIS IS UNTESTED! USE AT YOUR OWN RISK) + - `"ntp"`: use an NTP source as the external clock. Requires wifi-supported Pico and valid network configuration + (see WiFi setup below) +- `NTP_SERVER`: if `RTC_IMPLEMENTATION` is `ntp`, sets the NTP server to use as a clock source. Default: `0.pool.ntp.org`. Timezone options: - `UTC_OFFSET_HOURS`: The number of hours ahead/behind UTC the local timezone is (-24 to +24) - `UTC_OFFSET_MINUTES`: The number of minutes ahead/behind UTC the local timezone is (-59 to +59) +WiFi connection options: +- `WIFI_MODE`: the wireless operation mode, either `client` or `access_point`. Default: `access_point` +- `WIFI_SSID`: the SSID of the wireless network to connect to (in `client` mode) or to broadcast + (in `access_point` mode) +- `WIFI_PASSWORD`: the password of the wireless network +- `WIFI_CHANNEL`: the WiFi channel 1-13 to use in `access_point` mode; ignored in `client` mode. Default: `10` + +WiFi options are only applicable if EuroPi has the Raspberry Pi Pico W or Raspberry Pi Pico 2 W board; +other Pico models do not contain wireless support + # Accessing config members in Python code The firmware converts the JSON file into a `ConfigSettings` object, where the JSON keys are converted From 746b115a5fbe84e99e093fd201bf82921b3d46bf Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Thu, 13 Feb 2025 16:56:37 -0500 Subject: [PATCH 05/38] Add initial NTP implementation for realtime clock support --- .../experimental/clocks/ntp_source.py | 63 +++++++++++++++++++ software/firmware/experimental/rtc.py | 5 +- 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 software/firmware/experimental/clocks/ntp_source.py diff --git a/software/firmware/experimental/clocks/ntp_source.py b/software/firmware/experimental/clocks/ntp_source.py new file mode 100644 index 000000000..b32d6c2b2 --- /dev/null +++ b/software/firmware/experimental/clocks/ntp_source.py @@ -0,0 +1,63 @@ +# Copyright 2025 Allen Synthesis +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Interface for using an NTP server as a realtime clock source + +This will only work if +1. we're using a wifi-supported Pico model +2. we have a valid network configuration +3. there's an accessible NTP server on the network (important if we're in AP mode) +""" +from experimental.clocks.clock_source import ExternalClockSource +from experimental.experimental_config import load_experimental_config + +import ntptime +import utime + + +class NtpError(Exception): + """Custom NTP-related errors""" + def __init__(self, message): + super().__init__(message) + + +class NtpClock(ExternalClockSource): + def __init__(self): + super().__init__() + cfg = load_experimental_config() + try: + ntptime.settime(cfg.UTC_OFFSET_HOURS + cfg.UTC_OFFSET_MINUTES / 60, cfg.NTP_SERVER) + except Exception as err: + raise NtpError(f"Failed to initialize NTP clock: {err}") + + def set_datetime(self, datetme): + """ + NTP server is read-only, so setting the time is nonsical. + + This function won't do anything, but don't raise an error. + """ + pass + + def datetime(self): + """ + Get the latest time from our NTP source and return it + + @return a tuple of the form (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) + """ + # TODO + # see: https://mpython.readthedocs.io/en/v2.2.1/library/micropython/ntptime.html + # i'm not sure the Pico implementation of utime supports .localtime() + # testing needed. + t = utime.localtime() + return t # TODO: won't work; what does localtime() actually return? diff --git a/software/firmware/experimental/rtc.py b/software/firmware/experimental/rtc.py index 5c6f76fc2..88f001f2b 100644 --- a/software/firmware/experimental/rtc.py +++ b/software/firmware/experimental/rtc.py @@ -24,7 +24,7 @@ import europi from experimental.clocks.clock_source import ExternalClockSource -from experimental.experimental_config import RTC_NONE, RTC_DS1307, RTC_DS3231 +from experimental.experimental_config import RTC_DS1307, RTC_DS3231, RTC_NTP class Month: @@ -381,6 +381,9 @@ def localnow(self): elif europi.experimental_config.RTC_IMPLEMENTATION == RTC_DS3231: from experimental.clocks.ds3231 import DS3231 source = DS3231(europi.external_i2c) +elif europi.experimental_config.RTC_IMPLEMENTATION == RTC_NTP: + from experimental.clocks.ntp_source import NtpClock + source = NtpClock() else: from experimental.clocks.null_clock import NullClock source = NullClock() From 97ed1f85e1bf1df46ba17ae3643217af1a0096c7 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Thu, 13 Feb 2025 18:37:12 -0500 Subject: [PATCH 06/38] Add additional module dependencies needed for NTP. Catch import errors in the wifi and ntp modules as appropriate, re-raising them with our wrapper errors. Remove depenencies on mpython, stick to as much vanilla MicroPython as we can (plus micropython-socket and ntptime) --- software/CONFIGURATION.md | 39 +++++++--- .../clocks/{ntp_source.py => ntp.py} | 15 ++-- .../experimental/experimental_config.py | 5 +- software/firmware/experimental/rtc.py | 2 +- software/firmware/experimental/wifi.py | 73 ++++++++++++------- software/realtime_clock.md | 17 +++++ software/tests/mocks/mpython.py | 27 ------- software/tests/mocks/network.py | 57 +++++++++++++++ software/tests/mocks/utime.py | 6 ++ 9 files changed, 168 insertions(+), 73 deletions(-) rename software/firmware/experimental/clocks/{ntp_source.py => ntp.py} (85%) delete mode 100644 software/tests/mocks/mpython.py create mode 100644 software/tests/mocks/network.py diff --git a/software/CONFIGURATION.md b/software/CONFIGURATION.md index 476039a25..92eeeed65 100644 --- a/software/CONFIGURATION.md +++ b/software/CONFIGURATION.md @@ -27,7 +27,9 @@ default configuration: } ``` -System options: +## System + +Options: - `EUROPI_MODEL` specifies the type of EuroPi module. Currently only `"europi"` is supported. Default: `"europi"` - `PICO_MODEL` must be one of - `"pico"`, @@ -41,7 +43,9 @@ System options: - `MENU_AFTER_POWER_ON` is a boolean indicating whether or not the module should always return to the main menu when it powers on. By default the EuroPi will re-launch the last-used program instead of returning to the main menu. Default: `false` -Display options: +## Display + +Options: - `ROTATE_DISPLAY` must be one of `false` or `true`. Default: `false` - `DISPLAY_WIDTH` is the width of the screen in pixels. The standard EuroPi screen is 128 pixels wide. Default: `128` - `DISPLAY_HEIGHT` is the height of the screen in pixels. The standard EuroPi screen is 32 pixels tall. Default: `32` @@ -50,14 +54,18 @@ Display options: - `DISPLAY_CHANNEL` is the I²C channel used for the display, either 0 or 1. Default: `0` - `DISPLAY_CONTRAST` is a value indicating the display contrast. Higher numbers give higher contrast. `0` to `255`. Default: `255` -External I²C options: +## External I²C + +Options: - `EXTERNAL_I2C_SDA` is the I²C SDA pin used for the external I²C interface. Only SDA capable pis can be selected. Default: `2` - `EXTERNAL_I2C_SCL` is the I²C SCL pin used for the external I²C interface. Only SCL capable pins can be selected. Default: `3` - `EXTERNAL_I2C_CHANNEL` is the I²C channel used for the external I²C interface, either 0 or 1. Default: `1` - `EXTERNAL_I2C_FREQUENCY` is the I²C frequency used for the external I²C interface. Default: `100000` - `EXTERNAL_I2C_TIMEOUT` is the I²C timeout in milliseconds for the external I²C interface. Default: `1000` -I/O voltage options: +## I/O voltage + +Options: - `MAX_OUTPUT_VOLTAGE` is a float in the range `[0.0, 10.0]` indicating the maximum voltage CV output can generate. Default: `10.0` The hardware is capable of 10V maximum - `MAX_INPUT_VOLTAGE` is a float in the range `[0.0, 12.0]` indicating the maximum allowed voltage into the `ain` jack. @@ -71,33 +79,42 @@ limits are intended for broad compatibility configuration, not for precise tunin If you assembled your module with the Raspberry Pi Pico 2 (or a clone featuring the RP2350 microcontroller) make sure to set the `PICO_MODEL` setting to `"pico2"`. - # Experimental configuration Other configuration properties are used by [experimental features](/software/firmware/experimental/__init__.py) and can be set using a similar static configuration file. This file is located at `/config/ExperimentalConfig.json` on the Raspberry Pi Pico. If this file does not exist, default settings will be loaded. -Quantization options: +## Quantization + +Options: - `VOLTS_PER_OCTAVE` must be one of `1.0` (Eurorack standard) or `1.2` (Buchla standard). Default: `1.0` -RTC options: +## Realtime Clock (RTC) + +Options: - `RTC_IMPLEMENTATION` is one of the following, representing the realtime clock enabled on your module: - `""`: there is no RTC present. (default) - `"ds3231"`: use a DS3231 module connected to the external I2C interface - `"ds1307"`: use a DS1307 module connected to the external I2C interface (THIS IS UNTESTED! USE AT YOUR OWN RISK) - `"ntp"`: use an NTP source as the external clock. Requires wifi-supported Pico and valid network configuration - (see WiFi setup below) -- `NTP_SERVER`: if `RTC_IMPLEMENTATION` is `ntp`, sets the NTP server to use as a clock source. Default: `0.pool.ntp.org`. + (see WiFi connection below) +- `NTP_SERVER`: if `RTC_IMPLEMENTATION` is `ntp`, sets the NTP server to use as a clock source. + Default: `0.pool.ntp.org`. -Timezone options: +## Timezone + +Options: - `UTC_OFFSET_HOURS`: The number of hours ahead/behind UTC the local timezone is (-24 to +24) - `UTC_OFFSET_MINUTES`: The number of minutes ahead/behind UTC the local timezone is (-59 to +59) -WiFi connection options: +## WiFi Connection + +Options: - `WIFI_MODE`: the wireless operation mode, either `client` or `access_point`. Default: `access_point` - `WIFI_SSID`: the SSID of the wireless network to connect to (in `client` mode) or to broadcast (in `access_point` mode) +- `WIFI_BSSID`: the optional BSSID of the network to connect to (e.g. access point MAC address). Default: `""` - `WIFI_PASSWORD`: the password of the wireless network - `WIFI_CHANNEL`: the WiFi channel 1-13 to use in `access_point` mode; ignored in `client` mode. Default: `10` diff --git a/software/firmware/experimental/clocks/ntp_source.py b/software/firmware/experimental/clocks/ntp.py similarity index 85% rename from software/firmware/experimental/clocks/ntp_source.py rename to software/firmware/experimental/clocks/ntp.py index b32d6c2b2..3424df9fd 100644 --- a/software/firmware/experimental/clocks/ntp_source.py +++ b/software/firmware/experimental/clocks/ntp.py @@ -22,15 +22,19 @@ from experimental.clocks.clock_source import ExternalClockSource from experimental.experimental_config import load_experimental_config -import ntptime import utime - class NtpError(Exception): """Custom NTP-related errors""" def __init__(self, message): super().__init__(message) +try: + import ntptime + import socket +except ImportError as err: + raise NtpError(f"Failed to load NTP dependency: {err}") + class NtpClock(ExternalClockSource): def __init__(self): @@ -55,9 +59,4 @@ def datetime(self): @return a tuple of the form (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) """ - # TODO - # see: https://mpython.readthedocs.io/en/v2.2.1/library/micropython/ntptime.html - # i'm not sure the Pico implementation of utime supports .localtime() - # testing needed. - t = utime.localtime() - return t # TODO: won't work; what does localtime() actually return? + return utime.gmtime() diff --git a/software/firmware/experimental/experimental_config.py b/software/firmware/experimental/experimental_config.py index e0f2cccfc..06ca1a4d4 100644 --- a/software/firmware/experimental/experimental_config.py +++ b/software/firmware/experimental/experimental_config.py @@ -116,7 +116,10 @@ def config_points(cls): maximum=13, default=10, ), - + configuration.string( + "WIFI_BSSID", + default="", + ), ] # fmt: on diff --git a/software/firmware/experimental/rtc.py b/software/firmware/experimental/rtc.py index 88f001f2b..45dc48788 100644 --- a/software/firmware/experimental/rtc.py +++ b/software/firmware/experimental/rtc.py @@ -382,7 +382,7 @@ def localnow(self): from experimental.clocks.ds3231 import DS3231 source = DS3231(europi.external_i2c) elif europi.experimental_config.RTC_IMPLEMENTATION == RTC_NTP: - from experimental.clocks.ntp_source import NtpClock + from experimental.clocks.ntp import NtpClock source = NtpClock() else: from experimental.clocks.null_clock import NullClock diff --git a/software/firmware/experimental/wifi.py b/software/firmware/experimental/wifi.py index 08407a552..2c12042d0 100644 --- a/software/firmware/experimental/wifi.py +++ b/software/firmware/experimental/wifi.py @@ -15,6 +15,9 @@ Enables managing the wireless connection on the Pico W and Pico 2 W Wifi credentials are saved via experimental_config + +See https://docs.micropython.org/en/latest/library/network.WLAN.html for details on +wifi implementation """ from europi_config import ( load_europi_config, @@ -22,7 +25,6 @@ MODEL_PICO_2W, ) from experimental.experimental_config import * -from mpython import wifi class WifiError(Exception): @@ -39,37 +41,58 @@ class WifiConnection: Raises a WifiError if the model doesn't support wifi """ def __init__(self): + try: + import network + except ImportError: + raise WifiError("Failed to import network module") + eu_cfg = load_europi_config() ex_cfg = load_experimental_config() if eu_cfg.PICO_MODEL != MODEL_PICO_2W and eu_cfg.PICO_MODEL != MODEL_PICO_W: raise WifiError(f"Hardware {eu_cfg.PICO_MODEL} doesn't support wifi") - self.ssid = ex_cfg.WIFI_SSID - self.password = ex_cfg.WIFI_PASSWORD - self.wifi = wifi() + ssid = ex_cfg.WIFI_SSID + password = ex_cfg.WIFI_PASSWORD + channel = ex_cfg.WIFI_CHANNEL + if ex_cfg.WIFI_BSSID: + bssid = ex_cfg.WIFI_BSSID + else: + bssid = None if ex_cfg.WIFI_MODE == WIFI_MODE_AP: - self.enable_ap() + try: + self._nic = network.WLAN(network.WLAN.IF_AP) + self._nic.config( + ssid=ssid, + channel=channel, + key=password, + ) + except Exception as err: + raise WifiError(f"Failed to enable AP mode: {err}") else: - self.connect() + try: + self._nic = network.WLAN(network.WLAN.IF_STA) + self._nic.connect( + ssid=ssid, + key=password, + bssid=bssid, + ) + except Exception as err: + raise WifiError(f"Failed to connect to network {ssid}: {err}") - def connect(self): - try: - self.wifi = wifi() - wifi.connectWiFi( - self.ssid, - self.password, - timeout=30 - ) - except Exception as err: - raise WifiError(f"Failed to connect to network: {err}") - - def disconnect(self): - self.wifi.disconnectWiFi() - - def enable_ap(self): - self.wifi.enable_APWiFi(self.ssid, self.password, timeout=30) - - def disable_ap(self): - self.wifi.disable_APWiFi() \ No newline at end of file + @property + def is_connected(self): + """ + Is the Pico connected to anything? + + In client mode this returns True if we're connected to an access point + + In AP mode this returns True if at least one client is connected to us + """ + return self._nic.isconnected() + + @property + def interface(self): + """Get the underlying network.WLAN interface""" + return self._nic diff --git a/software/realtime_clock.md b/software/realtime_clock.md index 8e0b35ae9..416a38f09 100644 --- a/software/realtime_clock.md +++ b/software/realtime_clock.md @@ -122,6 +122,23 @@ autumn, you will need to manually adjust EuroPi twice per year to keep the local The sign of the minutes and hours must match. +## NTP Clock + +If you have a Raspberry Pi Pico W or Pico 2 W, you can use the Network Time Protocol (NTP) to provide the external +clock signal to your module. To do this you must: + +1. Use Thonny to install the following _additional_ packages on your module: + - `micropython-socket` + - `ntptime` +1. [Connect to a wireless network](/software/CONFIGURATION.md#experimental-configuration) by entering your network's + SSID and password into the experimental configuration file +1. Set the `RTC_IMPLEMENTATION` to `ntp` +1. (optional) Enter a valid, accessible NTP server. By default `0.pool.ntp.org` will be used, but you may wish to use + a different NTP server closer to your location. + +Once configured, restart the module. Your EuroPi should connect to the wifi network and get clock data from the +specified NTP server. + ## Troubleshooting If you see a warning of the form diff --git a/software/tests/mocks/mpython.py b/software/tests/mocks/mpython.py deleted file mode 100644 index c62a2cb37..000000000 --- a/software/tests/mocks/mpython.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright 2025 Allen Synthesis -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -class wifi: - def __init__(self): - pass - - def connectWiFi(self, ssid, passwd, timeout=10): - pass - def disconnectWiFi(self): - pass - - def enable_APWiFi(self, essid, password=b'',channel=10): - pass - - def disable_APWiFi(self): - pass \ No newline at end of file diff --git a/software/tests/mocks/network.py b/software/tests/mocks/network.py new file mode 100644 index 000000000..e72eb3433 --- /dev/null +++ b/software/tests/mocks/network.py @@ -0,0 +1,57 @@ +# Copyright 2025 Allen Synthesis +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class WIFI: + """ + Dummy implementation if MicroPython's network.WIFI class + + See: https://docs.micropython.org/en/latest/library/network.WLAN.html + """ + IF_STA = 0 + IF_AP = 1 + + PM_PERFORMANCE = 0 + PM_POWERSAVE = 1 + PM_NONE = 2 + + def __init__(self, mode): + pass + + def connect(self, ssid=None, key=None, bssid=None): + pass + + def disconnect(self): + pass + + def config(self, params): + pass + + def ifconfig(self, params=None): + if params is None: + return ('10.0.0.100', '255.0.0.0', '10.0.0.1', '8.8.8.8') + else: + pass + + def active(self): + return False + + def isconnected(self): + return False + + def param(self, **kwargs): + pass + + def scan(self): + return [] diff --git a/software/tests/mocks/utime.py b/software/tests/mocks/utime.py index d91bb26b1..7a62e5f0e 100644 --- a/software/tests/mocks/utime.py +++ b/software/tests/mocks/utime.py @@ -29,3 +29,9 @@ def ticks_diff(*args): def ticks_ms(): return 0 + +def localtime(): + return (1970, 1, 1, 0, 0, 0) + +def gmtime(): + return (1970, 1, 1, 0, 0, 0) From 4e3258cff2bffea4cc09dc3b799c4d6fc5b210c7 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Thu, 13 Feb 2025 18:46:21 -0500 Subject: [PATCH 07/38] Formatting --- software/firmware/configuration.py | 16 +++++++--------- software/firmware/experimental/clocks/ntp.py | 8 ++++++++ software/firmware/experimental/wifi.py | 1 + software/tests/mocks/network.py | 5 ++++- software/tests/mocks/utime.py | 2 ++ 5 files changed, 22 insertions(+), 10 deletions(-) diff --git a/software/firmware/configuration.py b/software/firmware/configuration.py index 7c829c33b..f4a64a484 100644 --- a/software/firmware/configuration.py +++ b/software/firmware/configuration.py @@ -67,9 +67,8 @@ class StringConfigPoint(ConfigPoint): :param default: The default value :param danger: If true, mark this option as dangerous to modify in the config editor """ - def __init__( - self, name: str, default: str, danger: bool = False - ): + + def __init__(self, name: str, default: str, danger: bool = False): super().__init__(name=name, type=float, default=default, danger=danger) def validate(self, value) -> Validation: @@ -78,6 +77,7 @@ def validate(self, value) -> Validation: """ return type(value) is str + class FloatConfigPoint(ConfigPoint): """A `ConfigPoint` that requires the selection from a range of floats. The default value must lie within the specified range @@ -239,9 +239,8 @@ def integer( name=name, minimum=minimum, maximum=maximum, default=default, danger=danger ) -def string( - name: str, default: str, danger: bool = False -) -> StringConfigPoint: + +def string(name: str, default: str, danger: bool = False) -> StringConfigPoint: """A helper function to simplify the creation of StringConfigPoints. Allows the input of any arbitrary string @@ -249,9 +248,8 @@ def string( :param default: The default value :param danger: If true, mark this option as dangerous to modify in the config editor """ - return StringConfigPoint( - name=name, default=default, danger=danger - ) + return StringConfigPoint(name=name, default=default, danger=danger) + class ConfigSpec: """ diff --git a/software/firmware/experimental/clocks/ntp.py b/software/firmware/experimental/clocks/ntp.py index 3424df9fd..0d69c72b4 100644 --- a/software/firmware/experimental/clocks/ntp.py +++ b/software/firmware/experimental/clocks/ntp.py @@ -24,8 +24,10 @@ import utime + class NtpError(Exception): """Custom NTP-related errors""" + def __init__(self, message): super().__init__(message) @@ -37,6 +39,12 @@ def __init__(self, message): class NtpClock(ExternalClockSource): + """ + Realtime clock source that uses an external NTP server + + Requires a valid network connection on a Pico W or Pico 2 W + """ + def __init__(self): super().__init__() cfg = load_experimental_config() diff --git a/software/firmware/experimental/wifi.py b/software/firmware/experimental/wifi.py index 2c12042d0..407b9159d 100644 --- a/software/firmware/experimental/wifi.py +++ b/software/firmware/experimental/wifi.py @@ -40,6 +40,7 @@ class WifiConnection: Raises a WifiError if the model doesn't support wifi """ + def __init__(self): try: import network diff --git a/software/tests/mocks/network.py b/software/tests/mocks/network.py index e72eb3433..53d35807e 100644 --- a/software/tests/mocks/network.py +++ b/software/tests/mocks/network.py @@ -19,9 +19,12 @@ class WIFI: See: https://docs.micropython.org/en/latest/library/network.WLAN.html """ + + # TODO: are these correct? IF_STA = 0 IF_AP = 1 + # TODO: are these correct? PM_PERFORMANCE = 0 PM_POWERSAVE = 1 PM_NONE = 2 @@ -40,7 +43,7 @@ def config(self, params): def ifconfig(self, params=None): if params is None: - return ('10.0.0.100', '255.0.0.0', '10.0.0.1', '8.8.8.8') + return ("10.0.0.100", "255.0.0.0", "10.0.0.1", "8.8.8.8") else: pass diff --git a/software/tests/mocks/utime.py b/software/tests/mocks/utime.py index 7a62e5f0e..01c8a5732 100644 --- a/software/tests/mocks/utime.py +++ b/software/tests/mocks/utime.py @@ -30,8 +30,10 @@ def ticks_diff(*args): def ticks_ms(): return 0 + def localtime(): return (1970, 1, 1, 0, 0, 0) + def gmtime(): return (1970, 1, 1, 0, 0, 0) From 5a2e7f4d822828e8f34bec2ca1a8396170500492 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Thu, 13 Feb 2025 18:47:23 -0500 Subject: [PATCH 08/38] Newline --- software/firmware/experimental/clocks/ntp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/software/firmware/experimental/clocks/ntp.py b/software/firmware/experimental/clocks/ntp.py index 0d69c72b4..2e56fc7a4 100644 --- a/software/firmware/experimental/clocks/ntp.py +++ b/software/firmware/experimental/clocks/ntp.py @@ -31,6 +31,7 @@ class NtpError(Exception): def __init__(self, message): super().__init__(message) + try: import ntptime import socket From 1f3171e456c0d2ca6ec06faafcbca38942308295 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Thu, 13 Feb 2025 21:32:55 -0500 Subject: [PATCH 09/38] Update the programming instructions for all pico-family variants, not just the Pico 2 --- software/programming_instructions.md | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/software/programming_instructions.md b/software/programming_instructions.md index 82feeb9f7..cf6fefed6 100644 --- a/software/programming_instructions.md +++ b/software/programming_instructions.md @@ -29,7 +29,7 @@ The quickest way to get your EuroPi flashed with the latest firmware is to head ## Pico Setup This section assumes you are using the Raspberry Pi Pico (or a compatible clone) featuring the RP2040 processor. If you -are using the newer Raspberry Pi Pico 2, with the RP2350 processor, see [below](#pico-2-setup). +are using a different model of Pico, e.g. the Pico W, or Pico 2, see [below](#alternative-picos). ### Downloading Thonny To start with, you'll need to download the [Thonny IDE](https://thonny.org/). This is what you will use to program and debug the module. @@ -94,19 +94,27 @@ The EuroPi Contrib library will make user-contributed software available on your 1. Click 'Install'. 1. You will now see a `contrib` folder inside the `lib` folder which contains several software options with the extension `.py`. -## Pico 2 Setup +## Alternative Picos -If you are using the Raspberry Pi Pico 2, or a compatible clone, with the RP2350 processor, you must download the -MicroPython firmware for that board: -- [Download here](https://micropython.org/download/RPI_PICO2/). At the time of writing, the latest supported version is 1.24.0 +The Raspberry Pi Pico family has several different versions, all of which are mechanically and electronically interchangeable. This means +you can swap the Raspberry Pi Pico for the newer Pico 2 to take advantage of the RP2350 CPU's higher clock speed, or a Pico W/Pico 2 W +to add wi-fi connectivity to your EuroPi. + +Each Pico model has its own MicroPython firmware: +- [Raspberry Pi Pico](https://micropython.org/download/RPI_PICO/) (the default for EuroPi) +- [Raspberry Pi Pico W](https://micropython.org/download/RPI_PICO_W/) +- [Raspberry Pi Pico 2](https://micropython.org/download/RPI_PICO2/) +- [Raspberry Pi Pico 2 W](https://micropython.org/download/RPI_PICO2_W/) +It is recommended to download the latest stable release (`1.24.1` at the time of writing) for your Pico model, if possible. +At the time of writing, the Pico 2 W's latest version of `1.25.0-preview-*`. Once the firmware is installed, continue installing the rest of the software: 1. [Installing the OLED library](#installing-the-oled-library) 2. [Installing the EuroPi library](#installing-the-oled-library) 3. [Installing the EuroPi Contrib library](#optional-installing-the-europi-contrib-library) -Once the software is installed, you will need to [configure the software](/software/CONFIGURATION.md) to finish setting up -the Pico 2. +Once the software is installed, you will need to [configure the software](/software/CONFIGURATION.md#system) to finish setting up +the Pico. ## Next Steps From 0e522abe049c24397a359d132a2092a73f0f6dd2 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Fri, 14 Feb 2025 00:05:49 -0500 Subject: [PATCH 10/38] Start implementing a basic HTTP server and a server-based control script --- software/contrib/http_control.py | 42 +++++ software/firmware/experimental/http_server.py | 165 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 software/contrib/http_control.py create mode 100644 software/firmware/experimental/http_server.py diff --git a/software/contrib/http_control.py b/software/contrib/http_control.py new file mode 100644 index 000000000..28a0bba5a --- /dev/null +++ b/software/contrib/http_control.py @@ -0,0 +1,42 @@ +# Copyright 2025 Allen Synthesis +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Serves a simple HTTP page to control the levels of the six CV outputs from a browser + +Requires a Pico W or Pico 2 W with a valid wifi setup to work +""" + +from europi import * +from europi_script import EuroPiScript + +from experimental.http import * + +class HttpControl(EuroPiScript): + + def __init__(self): + super().__init__() + + self.server = HttpServer(80) + + @self.server.request_handler + def handle_request(connection=None, request=None): + raise NotImplementedError("WIP - Not implemented yet") + + def main(self): + while True: + self.server.check_requests() + + +if __name__ == "__main__": + HttpControl().main() diff --git a/software/firmware/experimental/http_server.py b/software/firmware/experimental/http_server.py new file mode 100644 index 000000000..93ff92821 --- /dev/null +++ b/software/firmware/experimental/http_server.py @@ -0,0 +1,165 @@ +# Copyright 2025 Allen Synthesis +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +A simple HTTP server for the Raspberry Pi Pico +""" + +try: + import socket +except ImportError as err: + raise Exception(f"Failed to load HTTP server dependencies: {err}") + + +class HttpStatus: + """ + HTTP status codes to include in the header. + + This collection is not exhaustive, just what we need to handle this minimal server implementation + """ + OK = "200 OK" + + BAD_REQUEST = "400 Bad Request" + FORBIDDEN = "403 Forbidden" + NOT_FOUND = "404 Not Found" + TEAPOT = "418 I'm a teapot" # for fun & debugging! + + INTERNAL_SERVER_ERROR = "500 Internal Server Error" + NOT_IMPLEMENTED = "501 Not Implemented" + +class MimeTypes: + """ + Common MIME types we can support with this HTTP server implementation + """ + CSV = "text/csv" + HTML = "text/html" + JSON = "text/json" + TEXT = "text/plain" + XML = "text/xml" + YAML = "text/yaml" + + +class HttpServer: + """ + The main server instance + + You should define a callback to handle incoming requests, e.g.: + + server = HttpServer(port=8080) + + @server.request_handler + def handle_http_request(request:str=None, conn:Socket=None): + # process the request + server.send_response() + """ + + # A basic error page template + ERROR_PAGE = """ + + + EuroPi Error: {title} + + +

EuroPi Error

+

{title}

+

{body}

+ + +""" + + def __init__(self, port=80): + self.port = 80 + + self.request_callback = lambda *args, **kwargs: None + + if wifi_connection is None: + raise WifiError("Unable to start HTTP server: no wifi connection") + + self.socket = socket.socket() + addr = socket.getaddrinfo('0.0.0.0', port)[0][-1] + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(addr) + s.listen() + + def check_requests(self): + """ + Poll the socket and process any incoming requests. + + This will invoke the request handler function if it has been defined + + This function should be called inside the main loop of the program + """ + try: + conn, addr = self.socket.accept() + + request = conn.recv(2048) + request = str(request=request, connection=conn) + + self.request_callback(request) + + except OSError as err: + print(f"Connection closed: {err}") + except NotImplementedError as err: + self.send_response( + conn, + self.ERROR_PAGE.format( + title=HttpStatus.NOT_IMPLEMENTED, + body=str(err), + ), + status=HttpStatus.NOT_IMPLEMENTED, + content_type="text/html" + ) + except Exception as err: + self.send_response( + conn, + self.ERROR_PAGE.format( + title=HttpStatus.INTERNAL_SERVER_ERROR, + body=str(err), + ), + status=HttpStatus.INTERNAL_SERVER_ERROR, + content_type="text/html" + ) + + def request_handler(self, func): + """ + Decorator for the function to handle HTTP requests + + The provided function must accept the following keyword arguments: + - request: str The request the client sent + - conn: socket A socket connection to the client + + @param func The function to handle the request. + """ + def wrapper(*args, **kwargs): + func(*args, **kwargs) + + self.request_callback = wrapper + return wrapper + + def send_response( + self, + connection:socket.socket, + response:str, + status:str=HttpStatus.OK, + content_type:str=MimeTypes.HTML, + ): + """ + Send a response to the client + + @param connection The socket connection to the client + @param response The response payload + @param content_type The MIME type to include in the HTTP header + """ + connection.send(f"HTTP/1.0 200 OK\r\nContent-type: {content_type}\r\n\r\n") + connection.send(response) + connection.close() From b0fa79f58178cdd3ba74607212e60e1e41aa20b2 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Fri, 14 Feb 2025 00:18:51 -0500 Subject: [PATCH 11/38] Formatting --- software/firmware/experimental/http_server.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/software/firmware/experimental/http_server.py b/software/firmware/experimental/http_server.py index 93ff92821..44244bb4c 100644 --- a/software/firmware/experimental/http_server.py +++ b/software/firmware/experimental/http_server.py @@ -27,6 +27,7 @@ class HttpStatus: This collection is not exhaustive, just what we need to handle this minimal server implementation """ + OK = "200 OK" BAD_REQUEST = "400 Bad Request" @@ -37,10 +38,12 @@ class HttpStatus: INTERNAL_SERVER_ERROR = "500 Internal Server Error" NOT_IMPLEMENTED = "501 Not Implemented" + class MimeTypes: """ Common MIME types we can support with this HTTP server implementation """ + CSV = "text/csv" HTML = "text/html" JSON = "text/json" @@ -86,7 +89,7 @@ def __init__(self, port=80): raise WifiError("Unable to start HTTP server: no wifi connection") self.socket = socket.socket() - addr = socket.getaddrinfo('0.0.0.0', port)[0][-1] + addr = socket.getaddrinfo("0.0.0.0", port)[0][-1] s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(addr) s.listen() @@ -117,7 +120,7 @@ def check_requests(self): body=str(err), ), status=HttpStatus.NOT_IMPLEMENTED, - content_type="text/html" + content_type=MimeTypes.HTML, ) except Exception as err: self.send_response( @@ -127,7 +130,7 @@ def check_requests(self): body=str(err), ), status=HttpStatus.INTERNAL_SERVER_ERROR, - content_type="text/html" + content_type=MimeTypes.HTML, ) def request_handler(self, func): @@ -140,6 +143,7 @@ def request_handler(self, func): @param func The function to handle the request. """ + def wrapper(*args, **kwargs): func(*args, **kwargs) @@ -148,10 +152,10 @@ def wrapper(*args, **kwargs): def send_response( self, - connection:socket.socket, - response:str, - status:str=HttpStatus.OK, - content_type:str=MimeTypes.HTML, + connection: socket.socket, + response: str, + status: str = HttpStatus.OK, + content_type: str = MimeTypes.HTML, ): """ Send a response to the client From 100a955627bbe9fc4cff5f0cc8d3dbb66962520c Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Fri, 14 Feb 2025 00:19:00 -0500 Subject: [PATCH 12/38] Show the ip address, netmask, and gateway on the screen --- software/contrib/http_control.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/software/contrib/http_control.py b/software/contrib/http_control.py index 28a0bba5a..2e50b4ae2 100644 --- a/software/contrib/http_control.py +++ b/software/contrib/http_control.py @@ -34,6 +34,14 @@ def handle_request(connection=None, request=None): raise NotImplementedError("WIP - Not implemented yet") def main(self): + (ip_addr, netmask, gateway, dns) = wifi_connection.interface.ifconfig() + + oled.fill(0) + oled.centre_text(f"""{ip_addr} +{netmask} +{gateway}""") + oled.show() + while True: self.server.check_requests() From 29125e768ff841bd675fa19778909911d45f8629 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Fri, 14 Feb 2025 00:28:49 -0500 Subject: [PATCH 13/38] Set the default SSID and password since we default to AP mode --- software/CONFIGURATION.md | 8 +++++--- software/firmware/experimental/experimental_config.py | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/software/CONFIGURATION.md b/software/CONFIGURATION.md index 92eeeed65..abbecf1d0 100644 --- a/software/CONFIGURATION.md +++ b/software/CONFIGURATION.md @@ -111,11 +111,13 @@ Options: ## WiFi Connection Options: -- `WIFI_MODE`: the wireless operation mode, either `client` or `access_point`. Default: `access_point` +- `WIFI_MODE`: the wireless operation mode, one of: +- `"access_point"` (default): EuroPi acts as a wireless access point for other devices to connect to + -`"client"`: connect EuroPi to an external wireless router or accesspoint (DHCP required) - `WIFI_SSID`: the SSID of the wireless network to connect to (in `client` mode) or to broadcast - (in `access_point` mode) + (in `access_point` mode). Default: `"EuroPi"` - `WIFI_BSSID`: the optional BSSID of the network to connect to (e.g. access point MAC address). Default: `""` -- `WIFI_PASSWORD`: the password of the wireless network +- `WIFI_PASSWORD`: the password of the wireless network. Default: `"europi"` - `WIFI_CHANNEL`: the WiFi channel 1-13 to use in `access_point` mode; ignored in `client` mode. Default: `10` WiFi options are only applicable if EuroPi has the Raspberry Pi Pico W or Raspberry Pi Pico 2 W board; diff --git a/software/firmware/experimental/experimental_config.py b/software/firmware/experimental/experimental_config.py index 06ca1a4d4..115df086e 100644 --- a/software/firmware/experimental/experimental_config.py +++ b/software/firmware/experimental/experimental_config.py @@ -104,11 +104,11 @@ def config_points(cls): ), configuration.string( "WIFI_SSID", - default="", + default="EuroPi", ), configuration.string( "WIFI_PASSWORD", - default="", + default="europi", ), configuration.integer( "WIFI_CHANNEL", From 9d5ef80c421d2ecabdd2d0f8323a03d631784c95 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Fri, 14 Feb 2025 11:25:58 -0500 Subject: [PATCH 14/38] Add a wait for the connection to become active --- software/contrib/http_control.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/software/contrib/http_control.py b/software/contrib/http_control.py index 2e50b4ae2..15d0d07db 100644 --- a/software/contrib/http_control.py +++ b/software/contrib/http_control.py @@ -34,13 +34,17 @@ def handle_request(connection=None, request=None): raise NotImplementedError("WIP - Not implemented yet") def main(self): - (ip_addr, netmask, gateway, dns) = wifi_connection.interface.ifconfig() + if wifi_connection is None: + raise WifiError("No wifi connection") - oled.fill(0) - oled.centre_text(f"""{ip_addr} -{netmask} -{gateway}""") - oled.show() + while not wifi_connection.is_connected: + oled.centre_text(f"""{wifi_connection.ssid} +Waiting for +connection...""") + + oled.centre_text(f"""{wifi_connection.ssid} +{wifi_connection.ip_addr} +Connected""") while True: self.server.check_requests() From 714ec4d9a6cfbf5ea67122767b3411ba2580f5ec Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Fri, 14 Feb 2025 11:26:31 -0500 Subject: [PATCH 15/38] Remove the optional fields from datetime tuples; make length consistently 8 to include yearday; this keeps all sources compatible with machine.RTC --- .../firmware/experimental/clocks/clock_source.py | 9 +++++++-- software/firmware/experimental/clocks/ds1307.py | 5 +++-- software/firmware/experimental/clocks/ds3231.py | 7 ++++--- software/firmware/experimental/clocks/ntp.py | 2 +- .../firmware/experimental/clocks/null_clock.py | 3 ++- software/firmware/experimental/rtc.py | 14 ++++++++++++++ 6 files changed, 31 insertions(+), 9 deletions(-) diff --git a/software/firmware/experimental/clocks/clock_source.py b/software/firmware/experimental/clocks/clock_source.py index 419acbe2d..d8cfe6c13 100644 --- a/software/firmware/experimental/clocks/clock_source.py +++ b/software/firmware/experimental/clocks/clock_source.py @@ -44,6 +44,7 @@ class ExternalClockSource: MINUTE = 4 SECOND = 5 WEEKDAY = 6 + YEARDAY = 7 # fmt: off # The lengths of the months in a non-leap-year @@ -70,7 +71,9 @@ def datetime(self): """ Get the current UTC time as a tuple. - @return a tuple of the form (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) + see: https://docs.micropython.org/en/latest/library/time.html#time.localtime + + @return a tuple of the form (0-year, 1-month, 2-day, 3-hour, 4-minutes, 5-seconds, 6-weekday, 7-yearday) """ raise NotImplementedError() @@ -81,7 +84,9 @@ def set_datetime(self, datetime): If the clock does not support setting (e.g. it's an NTP source we can only read from) your sub-class should implement this method anyway and simply pass. - @param datetime A tuple of the form (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) + see: https://docs.micropython.org/en/latest/library/time.html#time.localtime + + @param datetime A tuple of the form (0-year, 1-month, 2-day, 3-hour, 4-minutes, 5-seconds, 6-weekday, 7-yearday) """ raise NotImplementedError() diff --git a/software/firmware/experimental/clocks/ds1307.py b/software/firmware/experimental/clocks/ds1307.py index 292468e19..0b73b3ea8 100644 --- a/software/firmware/experimental/clocks/ds1307.py +++ b/software/firmware/experimental/clocks/ds1307.py @@ -66,7 +66,7 @@ def datetime(self): """ Get the current time. - @return datetime : tuple, (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) + @return a tuple of the form (0-year, 1-month, 2-day, 3-hour, 4-minutes, 5-seconds, 6-weekday, 7-yearday) """ buf = self.i2c.readfrom_mem(self.addr, DATETIME_REG, 7) # fmt: off @@ -78,6 +78,7 @@ def datetime(self): self._bcd2dec(buf[1]), # minute self._bcd2dec(buf[0] & 0x7F), # second self._bcd2dec(buf[3]), # weekday + 0, # yearday (ignored) ) # fmt: on @@ -85,7 +86,7 @@ def set_datetime(self, datetime): """ Set the current time. - @param datetime : tuple, (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) + @param datetime : tuple of the form (0-year, 1-month, 2-day, 3-hour, 4-minutes, 5-seconds, 6-weekday, 7-yearday) """ self.check_valid_datetime(datetime) diff --git a/software/firmware/experimental/clocks/ds3231.py b/software/firmware/experimental/clocks/ds3231.py index e4c5b38d2..7e91f06ac 100644 --- a/software/firmware/experimental/clocks/ds3231.py +++ b/software/firmware/experimental/clocks/ds3231.py @@ -44,7 +44,7 @@ >>> clock.source.set_datetime((2025, 6, 14, 22, 59, 0, 6)) This will set the clock to 14 June 2025, 22:59:00, and set the weekday to Saturday (6). -The tuple is of the form (Year, Month, Day, Hour, Minute [, Second[, Weekday]]). It is recommended +The tuple is of the form (Year, Month, Day, Hour, Minute, Second, Weekday, Yearday). It is recommended to set the seconds & weekday, but it is optional. Note that the clock _should_ be set to UTC, not local time. If you choose to use local time instead @@ -117,7 +117,7 @@ def datetime(self): Get the current time. Returns in 24h format, converts to 24h if clock is set to 12h format - @return a tuple, (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) + @return a tuple of the form (0-year, 1-month, 2-day, 3-hour, 4-minutes, 5-seconds, 6-weekday, 7-yearday) """ self.i2c.readfrom_mem_into(self.addr, DATETIME_REG, self._timebuf) # 0x00 - Seconds BCD @@ -156,13 +156,14 @@ def datetime(self): minutes, seconds, weekday, + 0, ) def set_datetime(self, datetime): """ Set the current time. - @param datetime : tuple, (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) + @param datetime : tuple of the form (0-year, 1-month, 2-day, 3-hour, 4-minutes, 5-seconds, 6-weekday, 7-yearday) """ self.check_valid_datetime(datetime) diff --git a/software/firmware/experimental/clocks/ntp.py b/software/firmware/experimental/clocks/ntp.py index 2e56fc7a4..de5b39ac0 100644 --- a/software/firmware/experimental/clocks/ntp.py +++ b/software/firmware/experimental/clocks/ntp.py @@ -66,6 +66,6 @@ def datetime(self): """ Get the latest time from our NTP source and return it - @return a tuple of the form (0-year, 1-month, 2-day, 3-hour, 4-minutes[, 5-seconds[, 6-weekday]]) + @return a tuple of the form tuple of the form (0-year, 1-month, 2-day, 3-hour, 4-minutes, 5-seconds, 6-weekday, 7-yearday) """ return utime.gmtime() diff --git a/software/firmware/experimental/clocks/null_clock.py b/software/firmware/experimental/clocks/null_clock.py index 05327cd44..1acf8c7b9 100644 --- a/software/firmware/experimental/clocks/null_clock.py +++ b/software/firmware/experimental/clocks/null_clock.py @@ -45,5 +45,6 @@ def datetime(self): mm = 1 yy = 1970 wd = (4 + dd) % 7 # 1 jan 1970 was a thursday + yd = 0 # ignore yearday - return (yy, mm, dd, h, m, s, wd) + return (yy, mm, dd, h, m, s, wd, yd) diff --git a/software/firmware/experimental/rtc.py b/software/firmware/experimental/rtc.py index 45dc48788..bee7eac88 100644 --- a/software/firmware/experimental/rtc.py +++ b/software/firmware/experimental/rtc.py @@ -255,6 +255,19 @@ def days_in_year(self): else: return 365 + @property + def tuple(self): + return ( + self.year, + self.month, + self.day, + self.hour, + self.minute, + self.second, + self.weekday, + 0, + ) + def __eq__(self, other): # fmt: off return ( @@ -361,6 +374,7 @@ def utcnow(self): t[ExternalClockSource.MINUTE], t[ExternalClockSource.SECOND], t[ExternalClockSource.WEEKDAY], + t[ExternalClockSource.YEARDAY], ) def localnow(self): From 5d8b7fa7ad371724ae560f689e6a18d3dae2d296 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Fri, 14 Feb 2025 11:27:12 -0500 Subject: [PATCH 16/38] Add more properties to the wifi connection object --- software/firmware/experimental/wifi.py | 32 +++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/software/firmware/experimental/wifi.py b/software/firmware/experimental/wifi.py index 407b9159d..c90e514ea 100644 --- a/software/firmware/experimental/wifi.py +++ b/software/firmware/experimental/wifi.py @@ -61,6 +61,7 @@ def __init__(self): else: bssid = None + self._ssid = ssid if ex_cfg.WIFI_MODE == WIFI_MODE_AP: try: self._nic = network.WLAN(network.WLAN.IF_AP) @@ -83,7 +84,36 @@ def __init__(self): raise WifiError(f"Failed to connect to network {ssid}: {err}") @property - def is_connected(self): + def ip_addr(self) -> str: + """Get our current IP address""" + (addr, _, _, _) = self._nic.ifconfig() + return addr + + @property + def netmask(self) -> str: + """Get our current netmask""" + (_, netmask, _, _) = self._nic.ifconfig() + return netmask + + @property + def gateway(self) -> str: + """Get our current gateway""" + (_, _, gateway, _) = self._nic.ifconfig() + return gateway + + @property + def dns(self) -> str: + """Get our primary DNS""" + (_, _, _, dns) = self._nic.ifconfig() + return dns + + @property + def ssid(self) -> str: + """Get the SSID of our wireless network""" + return self._ssid + + @property + def is_connected(self) -> bool: """ Is the Pico connected to anything? From 00a89e91f14bf7073926d3b21c43bfed88b056d6 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Fri, 14 Feb 2025 11:44:10 -0500 Subject: [PATCH 17/38] Add a default request handler that just raises a NotImplemented error --- software/firmware/experimental/http_server.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/software/firmware/experimental/http_server.py b/software/firmware/experimental/http_server.py index 44244bb4c..03e8de816 100644 --- a/software/firmware/experimental/http_server.py +++ b/software/firmware/experimental/http_server.py @@ -83,7 +83,7 @@ def handle_http_request(request:str=None, conn:Socket=None): def __init__(self, port=80): self.port = 80 - self.request_callback = lambda *args, **kwargs: None + self.request_callback = self.default_request_handler if wifi_connection is None: raise WifiError("Unable to start HTTP server: no wifi connection") @@ -94,6 +94,9 @@ def __init__(self, port=80): s.bind(addr) s.listen() + def default_request_handler(self, connection=None, request=None): + raise NotImplementedError("No request handler set") + def check_requests(self): """ Poll the socket and process any incoming requests. @@ -106,12 +109,13 @@ def check_requests(self): conn, addr = self.socket.accept() request = conn.recv(2048) - request = str(request=request, connection=conn) - - self.request_callback(request) + request = str(request) + self.request_callback(request=request, connection=conn) + conn.close() except OSError as err: print(f"Connection closed: {err}") + conn.close() except NotImplementedError as err: self.send_response( conn, @@ -122,6 +126,7 @@ def check_requests(self): status=HttpStatus.NOT_IMPLEMENTED, content_type=MimeTypes.HTML, ) + conn.close() except Exception as err: self.send_response( conn, @@ -132,6 +137,7 @@ def check_requests(self): status=HttpStatus.INTERNAL_SERVER_ERROR, content_type=MimeTypes.HTML, ) + conn.close() def request_handler(self, func): """ @@ -166,4 +172,3 @@ def send_response( """ connection.send(f"HTTP/1.0 200 OK\r\nContent-type: {content_type}\r\n\r\n") connection.send(response) - connection.close() From 34c7251c54f6e67157ed7702441474fa4f029a6a Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Fri, 14 Feb 2025 16:40:43 -0500 Subject: [PATCH 18/38] Set the socket to non-blocking, loop when checking requests in case there's more than one to serve. Add additional exception handlers that indicate no pending connections --- software/firmware/experimental/http_server.py | 94 ++++++++++--------- 1 file changed, 51 insertions(+), 43 deletions(-) diff --git a/software/firmware/experimental/http_server.py b/software/firmware/experimental/http_server.py index 03e8de816..8723223f6 100644 --- a/software/firmware/experimental/http_server.py +++ b/software/firmware/experimental/http_server.py @@ -82,7 +82,6 @@ def handle_http_request(request:str=None, conn:Socket=None): def __init__(self, port=80): self.port = 80 - self.request_callback = self.default_request_handler if wifi_connection is None: @@ -90,9 +89,10 @@ def __init__(self, port=80): self.socket = socket.socket() addr = socket.getaddrinfo("0.0.0.0", port)[0][-1] - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - s.bind(addr) - s.listen() + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.socket.settimeout(0) + self.socket.bind(addr) + self.socket.listen() def default_request_handler(self, connection=None, request=None): raise NotImplementedError("No request handler set") @@ -105,39 +105,47 @@ def check_requests(self): This function should be called inside the main loop of the program """ - try: - conn, addr = self.socket.accept() - - request = conn.recv(2048) - request = str(request) - - self.request_callback(request=request, connection=conn) - conn.close() - except OSError as err: - print(f"Connection closed: {err}") - conn.close() - except NotImplementedError as err: - self.send_response( - conn, - self.ERROR_PAGE.format( - title=HttpStatus.NOT_IMPLEMENTED, - body=str(err), - ), - status=HttpStatus.NOT_IMPLEMENTED, - content_type=MimeTypes.HTML, - ) - conn.close() - except Exception as err: - self.send_response( - conn, - self.ERROR_PAGE.format( - title=HttpStatus.INTERNAL_SERVER_ERROR, - body=str(err), - ), - status=HttpStatus.INTERNAL_SERVER_ERROR, - content_type=MimeTypes.HTML, - ) - conn.close() + conn = None + while True: + try: + conn, addr = self.socket.accept() + + request = conn.recv(2048) + request = str(request) + + self.request_callback(request=request, connection=conn) + except NotImplementedError as err: + # send a 501 error page + self.send_response( + conn, + self.ERROR_PAGE.format( + title=HttpStatus.NOT_IMPLEMENTED, + body=str(err), + ), + status=HttpStatus.NOT_IMPLEMENTED, + content_type=MimeTypes.HTML, + ) + except OSError as err: + return + except BlockingIOError as err: + return + except TimeoutError as err: + return + except Exception as err: + # send a 500 error page + self.send_response( + conn, + self.ERROR_PAGE.format( + title=HttpStatus.INTERNAL_SERVER_ERROR, + body=str(err), + ), + status=HttpStatus.INTERNAL_SERVER_ERROR, + content_type=MimeTypes.HTML, + ) + finally: + if conn is not None: + conn.close() + conn = None def request_handler(self, func): """ @@ -158,10 +166,10 @@ def wrapper(*args, **kwargs): def send_response( self, - connection: socket.socket, - response: str, - status: str = HttpStatus.OK, - content_type: str = MimeTypes.HTML, + connection, + response, + status=HttpStatus.OK, + content_type=MimeTypes.HTML, ): """ Send a response to the client @@ -170,5 +178,5 @@ def send_response( @param response The response payload @param content_type The MIME type to include in the HTTP header """ - connection.send(f"HTTP/1.0 200 OK\r\nContent-type: {content_type}\r\n\r\n") - connection.send(response) + connection.send(f"HTTP/1.0 {status}\r\nContent-type: {content_type}\r\ncharset=utf-8\r\n\r\n".encode("UTF-8")) + connection.send(response.encode("UTF-8")) From 017a2646c9bbbf73449864f88af9128fb28830e7 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Fri, 14 Feb 2025 16:42:30 -0500 Subject: [PATCH 19/38] Import europi, use fully qualified name for wifi connection --- software/firmware/experimental/http_server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/software/firmware/experimental/http_server.py b/software/firmware/experimental/http_server.py index 8723223f6..54556e7c7 100644 --- a/software/firmware/experimental/http_server.py +++ b/software/firmware/experimental/http_server.py @@ -15,6 +15,8 @@ A simple HTTP server for the Raspberry Pi Pico """ +import europi + try: import socket except ImportError as err: @@ -84,7 +86,7 @@ def __init__(self, port=80): self.port = 80 self.request_callback = self.default_request_handler - if wifi_connection is None: + if europi.wifi_connection is None: raise WifiError("Unable to start HTTP server: no wifi connection") self.socket = socket.socket() From a8ca96da2ddcab57292c7a6c802c51d1e13c212e Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Fri, 14 Feb 2025 16:47:49 -0500 Subject: [PATCH 20/38] Import the wifi module so we can raise the exception properly --- software/firmware/experimental/http_server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/software/firmware/experimental/http_server.py b/software/firmware/experimental/http_server.py index 54556e7c7..83b931b61 100644 --- a/software/firmware/experimental/http_server.py +++ b/software/firmware/experimental/http_server.py @@ -16,6 +16,7 @@ """ import europi +import experimental.wifi try: import socket @@ -87,7 +88,7 @@ def __init__(self, port=80): self.request_callback = self.default_request_handler if europi.wifi_connection is None: - raise WifiError("Unable to start HTTP server: no wifi connection") + raise experimental.wifi.WifiError("Unable to start HTTP server: no wifi connection") self.socket = socket.socket() addr = socket.getaddrinfo("0.0.0.0", port)[0][-1] From 47d95b681ba805c4fba9830acd37767aef61e059 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Fri, 14 Feb 2025 16:52:11 -0500 Subject: [PATCH 21/38] Formatting --- software/firmware/experimental/http_server.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/software/firmware/experimental/http_server.py b/software/firmware/experimental/http_server.py index 83b931b61..2f003b741 100644 --- a/software/firmware/experimental/http_server.py +++ b/software/firmware/experimental/http_server.py @@ -181,5 +181,9 @@ def send_response( @param response The response payload @param content_type The MIME type to include in the HTTP header """ - connection.send(f"HTTP/1.0 {status}\r\nContent-type: {content_type}\r\ncharset=utf-8\r\n\r\n".encode("UTF-8")) + connection.send( + f"HTTP/1.0 {status}\r\nContent-type: {content_type}\r\ncharset=utf-8\r\n\r\n".encode( + "UTF-8" + ) + ) connection.send(response.encode("UTF-8")) From fcb06146cab998d43214e52ec1a1432f302ad33e Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sat, 15 Feb 2025 22:39:24 -0500 Subject: [PATCH 22/38] Add styling to the error page, change HTTP error codes to integers, add a dict with their human-readable names. Add a new function to format & serve the error page --- software/firmware/experimental/http_server.py | 143 +++++++++++++----- 1 file changed, 108 insertions(+), 35 deletions(-) diff --git a/software/firmware/experimental/http_server.py b/software/firmware/experimental/http_server.py index 2f003b741..81d0bc6a9 100644 --- a/software/firmware/experimental/http_server.py +++ b/software/firmware/experimental/http_server.py @@ -31,15 +31,31 @@ class HttpStatus: This collection is not exhaustive, just what we need to handle this minimal server implementation """ - OK = "200 OK" + OK = 200 - BAD_REQUEST = "400 Bad Request" - FORBIDDEN = "403 Forbidden" - NOT_FOUND = "404 Not Found" - TEAPOT = "418 I'm a teapot" # for fun & debugging! + BAD_REQUEST = 400 + UNAUTHORIZED = 401 + FORBIDDEN = 403 + NOT_FOUND = 404 + REQUEST_TIMEOUT = 408 + TEAPOT = 418 - INTERNAL_SERVER_ERROR = "500 Internal Server Error" - NOT_IMPLEMENTED = "501 Not Implemented" + INTERNAL_SERVER_ERROR = 500 + NOT_IMPLEMENTED = 501 + + StatusText = { + OK: "OK", + + BAD_REQUEST: "Bad Request", + UNAUTHORIZED: "Unauthorized", + FORBIDDEN: "Forbidden", + NOT_FOUND: "Not Found", + REQUEST_TIMEOUT: "Request Timeout", + TEAPOT: "I'm a teapot", + + INTERNAL_SERVER_ERROR: "Internal Server Error", + NOT_IMPLEMENTED: "Not Implemented", + } class MimeTypes: @@ -70,16 +86,54 @@ def handle_http_request(request:str=None, conn:Socket=None): """ # A basic error page template + # Contains 3 parameters for the user to fill: + # - errno: the error number (e.g. 404, 500) + # - errname: the human-legible error name (e.g Not Found, Internal Server Error) + # - message: free-form error message, stack trace, etc... ERROR_PAGE = """ - - - EuroPi Error: {title} - - -

EuroPi Error

-

{title}

-

{body}

- + + + + + EuroPi Error {errno} + + +
+

EuroPi

+

{errno} {errname}

+

+ {message} +

+
+ """ @@ -107,6 +161,8 @@ def check_requests(self): This will invoke the request handler function if it has been defined This function should be called inside the main loop of the program + + The client socket is closed after we send our response """ conn = None while True: @@ -119,15 +175,7 @@ def check_requests(self): self.request_callback(request=request, connection=conn) except NotImplementedError as err: # send a 501 error page - self.send_response( - conn, - self.ERROR_PAGE.format( - title=HttpStatus.NOT_IMPLEMENTED, - body=str(err), - ), - status=HttpStatus.NOT_IMPLEMENTED, - content_type=MimeTypes.HTML, - ) + self.send_error_page(err, conn, HttpStatus.NOT_IMPLEMENTED) except OSError as err: return except BlockingIOError as err: @@ -136,15 +184,7 @@ def check_requests(self): return except Exception as err: # send a 500 error page - self.send_response( - conn, - self.ERROR_PAGE.format( - title=HttpStatus.INTERNAL_SERVER_ERROR, - body=str(err), - ), - status=HttpStatus.INTERNAL_SERVER_ERROR, - content_type=MimeTypes.HTML, - ) + self.send_error_page(err, conn, HttpStatus.INTERNAL_SERVER_ERROR) finally: if conn is not None: conn.close() @@ -167,22 +207,55 @@ def wrapper(*args, **kwargs): self.request_callback = wrapper return wrapper + def send_error_page( + self, + error, + connection, + status=HttpStatus.INTERNAL_SERVER_ERROR, + ): + """ + Serve our customized HTTP error page + + @param error The exception that caused the error + @param connection The socket to send the response over + @param status The error status to respond with + """ + self.send_response( + connection, + self.ERROR_PAGE.format( + errno=status, + errname=HttpStatus.StatusText[status], + message=str(error), + ), + status=status, + content_type=MimeTypes.HTML, + ) + def send_response( self, connection, response, status=HttpStatus.OK, content_type=MimeTypes.HTML, + headers=None, ): """ Send a response to the client @param connection The socket connection to the client @param response The response payload + @param status The HTTP status to respond with @param content_type The MIME type to include in the HTTP header + @param headers Optional dict of key/value pairs for addtional HTTP headers. Charset is ALWAYS utf-8 """ + header = f"HTTP/1.0 {status} {HttpStatus.StatusText[status]}\r\nContent-type: {content_type}\r\ncharset=utf-8" + + if headers is not None: + for k in headers.keys(): + header = f"{header}\r\n{k}={headers[k]}" + connection.send( - f"HTTP/1.0 {status}\r\nContent-type: {content_type}\r\ncharset=utf-8\r\n\r\n".encode( + f"{header}\r\n\r\n".encode( "UTF-8" ) ) From c1fffd03cf07c0ac99729081f2eb83c8c1ddab72 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sat, 15 Feb 2025 22:42:20 -0500 Subject: [PATCH 23/38] Formatting --- software/firmware/experimental/http_server.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/software/firmware/experimental/http_server.py b/software/firmware/experimental/http_server.py index 81d0bc6a9..09b7d6bd9 100644 --- a/software/firmware/experimental/http_server.py +++ b/software/firmware/experimental/http_server.py @@ -254,9 +254,5 @@ def send_response( for k in headers.keys(): header = f"{header}\r\n{k}={headers[k]}" - connection.send( - f"{header}\r\n\r\n".encode( - "UTF-8" - ) - ) + connection.send(f"{header}\r\n\r\n".encode("UTF-8")) connection.send(response.encode("UTF-8")) From b65464644127d22786dfce07cbda0cd8da26b190 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sat, 15 Feb 2025 22:52:44 -0500 Subject: [PATCH 24/38] Use {{ and }} for the CSS in the page template --- software/firmware/experimental/http_server.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/software/firmware/experimental/http_server.py b/software/firmware/experimental/http_server.py index 09b7d6bd9..6aa8a1744 100644 --- a/software/firmware/experimental/http_server.py +++ b/software/firmware/experimental/http_server.py @@ -95,25 +95,25 @@ def handle_http_request(request:str=None, conn:Socket=None): EuroPi Error {errno} From 004477038a92cc22ad3072995e69864fa88dcf42 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sat, 15 Feb 2025 23:12:48 -0500 Subject: [PATCH 25/38] Add a comment block explaining how to work with the HttpServer class --- software/firmware/experimental/http_server.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/software/firmware/experimental/http_server.py b/software/firmware/experimental/http_server.py index 6aa8a1744..a87c3da75 100644 --- a/software/firmware/experimental/http_server.py +++ b/software/firmware/experimental/http_server.py @@ -138,6 +138,68 @@ def handle_http_request(request:str=None, conn:Socket=None): """ def __init__(self, port=80): + """ + Create a new HTTP server. + + This will open a socket on the specified port, allowing for clients to connect to us. + Only HTTP is supported, not HTTPS. Basic GET/POST requests should work, but websockets + and anything fancy likely won't. + + Port 80 is officially reserved for HTTP traffic, and can be used by default. Port 8080 + is also commonly used for HTTP traffic. + + If you're operating in WiFi Client mode you may be subject to the whims of whatever + port filter/firewall your IT administrator has implemented, so some ports may not work/ + may not be available. + + Operating in WiFi AP mode should allow the use of any port you want. + + After creating the HTTP server, you should assign a handler function to process requests: + + ```python + srv = HttpServer(8080) + @srv.request_handler + def handle_http_request(connection=None, request=None): + # 1. process the request, take any necessary actions + ... + my_response = f"{...}" + + # 2. send the response back + srv.send_response( + connection, + my_response, + HttpStatus.OK, + MimeTypes.HTML, + ) + ``` + Response can be an HTTP page, plain text, or JSON/CSV/YAML/XML formatted data. See + MimeTypes for supported types. The response should always be a string; if sending + a dict as JSON data you'll need to stringify it before passing it to send_response. + + You may send your own error codes as desired: + ```python + def handle_http_request(connection=None, request=None): + srv.send_error_page( + Exception("We're out of coffee!") + connection, + HttpStatus.TEAPOT, # send error 418 "I'm a teapot" + ) + ``` + + Inside the program's main loop you should call srv.check_connections() to process any + incoming requests: + + ```python + def main(self): + # ... + while True: + # ... + srv.check_requests() + # ... + ``` + + @param port The port to listen on + """ self.port = 80 self.request_callback = self.default_request_handler @@ -152,6 +214,16 @@ def __init__(self, port=80): self.socket.listen() def default_request_handler(self, connection=None, request=None): + """ + The default request handler for the server. + + The intent is that whatever program is using the HTTP server will create their own callback + to replace this function. So all we do is raise a NotImplementedError that's handled + by self.check_requests() and will serve our HTTP 501 error page accordingly. + + @param connection The socket the client connected on + @param request The client's request + """ raise NotImplementedError("No request handler set") def check_requests(self): From 04dd7359435d33731ad3d608572348ee3a2da6ee Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sat, 15 Feb 2025 23:15:37 -0500 Subject: [PATCH 26/38] Documentation for the error code blocks --- software/firmware/experimental/http_server.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/software/firmware/experimental/http_server.py b/software/firmware/experimental/http_server.py index a87c3da75..cee8510d6 100644 --- a/software/firmware/experimental/http_server.py +++ b/software/firmware/experimental/http_server.py @@ -31,8 +31,10 @@ class HttpStatus: This collection is not exhaustive, just what we need to handle this minimal server implementation """ + # 200 series - everything's fine OK = 200 + # 400 series - error is on the client end BAD_REQUEST = 400 UNAUTHORIZED = 401 FORBIDDEN = 403 @@ -40,19 +42,20 @@ class HttpStatus: REQUEST_TIMEOUT = 408 TEAPOT = 418 + # 500 series - error is on the server end INTERNAL_SERVER_ERROR = 500 NOT_IMPLEMENTED = 501 + # Human-readable names/descriptions of the error codes above + # If you add another error code, make sure to add it here too! StatusText = { OK: "OK", - BAD_REQUEST: "Bad Request", UNAUTHORIZED: "Unauthorized", FORBIDDEN: "Forbidden", NOT_FOUND: "Not Found", REQUEST_TIMEOUT: "Request Timeout", TEAPOT: "I'm a teapot", - INTERNAL_SERVER_ERROR: "Internal Server Error", NOT_IMPLEMENTED: "Not Implemented", } From b67a001d1905b6afa2569594e5b82a1c5c278379 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sun, 16 Feb 2025 03:30:29 -0500 Subject: [PATCH 27/38] Add basic HTML page with sliders for the CVs --- software/contrib/http_control.py | 120 +++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/software/contrib/http_control.py b/software/contrib/http_control.py index 15d0d07db..3209428c4 100644 --- a/software/contrib/http_control.py +++ b/software/contrib/http_control.py @@ -22,6 +22,126 @@ from experimental.http import * +HTTP_DOCUMENT = """ + + + + + EuroPi Web Control + + + + +

EuroPi Web Control

+
+ + + + + + + + + + + + + + + + + + + + + +
+ CV1 + + CV2 + + CV3 +
+ + + + + +
+ CV4 + + CV5 + + CV6 +
+ + + + + +
+
+ +""" + + class HttpControl(EuroPiScript): def __init__(self): From e5b66a8c01ea5a6afe3c41af728e7fc30c570441 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sun, 16 Feb 2025 13:24:52 -0500 Subject: [PATCH 28/38] Add convenience methods for sending JSON data, including a dedicated one for sending the current EuroPi state --- software/firmware/experimental/http_server.py | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/software/firmware/experimental/http_server.py b/software/firmware/experimental/http_server.py index cee8510d6..6345aeb5e 100644 --- a/software/firmware/experimental/http_server.py +++ b/software/firmware/experimental/http_server.py @@ -18,6 +18,8 @@ import europi import experimental.wifi +import json + try: import socket except ImportError as err: @@ -287,6 +289,7 @@ def send_error_page( error, connection, status=HttpStatus.INTERNAL_SERVER_ERROR, + headers=None, ): """ Serve our customized HTTP error page @@ -294,6 +297,7 @@ def send_error_page( @param error The exception that caused the error @param connection The socket to send the response over @param status The error status to respond with + @param headers Optional additional headers """ self.send_response( connection, @@ -304,6 +308,111 @@ def send_error_page( ), status=status, content_type=MimeTypes.HTML, + headers=headers + ) + + def send_current_state_json( + self, + connection, + headers=None, + ain=europi.ain, + din=europi.din, + b1=europi.b1, + b2=europi.b2, + k1=europi.k1, + k2=europi.k2, + cv1=europi.cv1, + cv2=europi.cv2, + cv3=europi.cv3, + cv4=europi.cv4, + cv5=europi.cv5, + cv6=europi.cv6, + ): + """ + Send the current state of the inputs & outputs as a JSON object + + The result object is of the form + ```json + { + "inputs": { + "ain": 0-MAX_INPUT_VOLTAGE, + "din": 0/1, + "k1": 0-1, + "k2": 0-1, + "b1": 0/1, + "b2": 0/1 + }, + "outputs": { + "cv1": 0-MAX_OUTPUT_VOLTAGE, + "cv2": 0-MAX_OUTPUT_VOLTAGE, + "cv3": 0-MAX_OUTPUT_VOLTAGE, + "cv4": 0-MAX_OUTPUT_VOLTAGE, + "cv5": 0-MAX_OUTPUT_VOLTAGE, + "cv6": 0-MAX_OUTPUT_VOLTAGE + } + } + ``` + + Calling this will re-sample the CV inputs; if this behaviour is not desired, you should + use a buffered wrapper for the input and pass that as the relevant parameter. + + @param connection The socket connection to the client + @param headers Optional additional HTTP headers to include + @param ain Must provide the read_voltage() method to return the current input voltage + @param din Must provide the value() method to return 0 or 1 + @param b1 Must provide the value() method to return 0 or 1 + @param b2 Must provide the value() method to return 0 or 1 + @param k1 Must provide the percent() method to return 0-1 + @param k2 Must provide the percent() method to return 0-1 + @param cv1 Must provide the voltage() method to return the current output voltage + @param cv2 Must provide the voltage() method to return the current output voltage + @param cv3 Must provide the voltage() method to return the current output voltage + @param cv4 Must provide the voltage() method to return the current output voltage + @param cv5 Must provide the voltage() method to return the current output voltage + @param cv6 Must provide the voltage() method to return the current output voltage + """ + self.send_json( + connection, + { + "inputs": { + "ain": ain.read_voltage(), + "din": din.value(), + "k1": k1.percent(), + "k2": k2.percent(), + "b1": b1.value(), + "b2": b2.value(), + }, + "outputs": { + "cv1": cv1.voltage(), + "cv2": cv2.voltage(), + "cv3": cv3.voltage(), + "cv4": cv4.voltage(), + "cv5": cv5.voltage(), + "cv6": cv6.voltage(), + } + }, + headers=headers + ) + + def send_json( + self, + connection, + data, + headers=None + ): + """ + Send a JSON object to the client + + @param connection The socket connection to the client + @param data A dict to be converted to a JSON object + @param headers Optional additional HTTP headers to include + """ + self.send_response( + connection, + json.dumps(data), + status=HttpStatus.OK, + content_type=MimeTypes.JSON, + headers=headers, ) def send_response( From 15dcca4d7c172bac1304b20d33e697adc8ef6537 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sun, 16 Feb 2025 13:28:30 -0500 Subject: [PATCH 29/38] Formatting --- software/firmware/experimental/http_server.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/software/firmware/experimental/http_server.py b/software/firmware/experimental/http_server.py index 6345aeb5e..c2ae91d47 100644 --- a/software/firmware/experimental/http_server.py +++ b/software/firmware/experimental/http_server.py @@ -308,7 +308,7 @@ def send_error_page( ), status=status, content_type=MimeTypes.HTML, - headers=headers + headers=headers, ) def send_current_state_json( @@ -389,16 +389,12 @@ def send_current_state_json( "cv4": cv4.voltage(), "cv5": cv5.voltage(), "cv6": cv6.voltage(), - } + }, }, - headers=headers + headers=headers, ) - def send_json( - self, - connection, - data, - headers=None + def send_json(self, connection, data, headers=None ): """ Send a JSON object to the client From 0410b6666a62e20dacf25eb2f12911c2cf6441a9 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sun, 16 Feb 2025 13:31:22 -0500 Subject: [PATCH 30/38] Superfluous newline --- software/firmware/experimental/http_server.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/software/firmware/experimental/http_server.py b/software/firmware/experimental/http_server.py index c2ae91d47..1f30c9665 100644 --- a/software/firmware/experimental/http_server.py +++ b/software/firmware/experimental/http_server.py @@ -394,8 +394,7 @@ def send_current_state_json( headers=headers, ) - def send_json(self, connection, data, headers=None - ): + def send_json(self, connection, data, headers=None): """ Send a JSON object to the client From 3ac2e61bceed11d94d60511a6797303d9322e109 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sun, 16 Feb 2025 14:02:48 -0500 Subject: [PATCH 31/38] Separate the request handling into GET and POST. Add some extra code for local development on desktop Python --- software/contrib/http_control.py | 19 +++- software/firmware/experimental/http_server.py | 97 ++++++++++++++++--- 2 files changed, 97 insertions(+), 19 deletions(-) diff --git a/software/contrib/http_control.py b/software/contrib/http_control.py index 3209428c4..2255bea2c 100644 --- a/software/contrib/http_control.py +++ b/software/contrib/http_control.py @@ -22,7 +22,7 @@ from experimental.http import * -HTTP_DOCUMENT = """ +HTML_DOCUMENT = """