diff --git a/micropython/minitz/manifest.py b/micropython/minitz/manifest.py
new file mode 100644
index 000000000..51612c738
--- /dev/null
+++ b/micropython/minitz/manifest.py
@@ -0,0 +1,3 @@
+metadata(description="MiniTZ timezone support.", version="0.1.0")
+
+module("minitz.py", opt=3)
diff --git a/micropython/minitz/minitz/__init__.py b/micropython/minitz/minitz/__init__.py
new file mode 100644
index 000000000..606692e43
--- /dev/null
+++ b/micropython/minitz/minitz/__init__.py
@@ -0,0 +1,43 @@
+from _minitz import Database
+import datetime as _datetime
+
+
+# Wraps a _minitz.Zone, and implements tzinfo
+class tzwrap(_datetime.tzinfo):
+    def __init__(self, mtz_zone):
+        self._mtz_zone = mtz_zone
+
+    def __str__(self):
+        return self.tzname(None)
+
+    # Returns (offset: int, designator: str, is_dst: int)
+    def _lookup_local(self, dt):
+        if dt.tzinfo is not self:
+            raise ValueError()
+        t = dt.replace(tzinfo=_datetime.timezone.utc, fold=0).timestamp()
+        return self._mtz_zone.lookup_local(t, dt.fold)
+
+    def utcoffset(self, dt):
+        return _datetime.timedelta(seconds=self._lookup_local(dt)[0])
+
+    def is_dst(self, dt):
+        # Nonstandard.  Returns bool.
+        return bool(self._lookup_local(dt)[2])
+
+    def dst(self, dt):
+        is_dst = self._lookup_local(dt)[2]
+        # TODO in the case of is_dst=1, this is returning
+        # a made-up value that may be wrong.
+        return _datetime.timedelta(hours=is_dst)
+
+    def tzname(self, dt):
+        return self._lookup_local(dt)[1]
+
+    def fromutc(self, dt):
+        if dt.fold != 0:
+            raise ValueError()
+        t = dt.replace(tzinfo=_datetime.timezone.utc).timestamp()
+        offset = self._mtz_zone.lookup_utc(t)[0]
+        _datetime.timedelta(seconds=offset)
+
+        return dt + self._offset
diff --git a/micropython/minitz/minitz/fetch.py b/micropython/minitz/minitz/fetch.py
new file mode 100644
index 000000000..a60b484f0
--- /dev/null
+++ b/micropython/minitz/minitz/fetch.py
@@ -0,0 +1,43 @@
+import requests
+import email.utils
+
+# Configurable settings
+server_url = 'http://tzdata.net/api/1/'
+dataset = '1-15'
+
+
+def fetch(url, last_modified, timeout=None):
+    headers = {}
+    if last_modified:
+        headers['if-modified-since'] = email.utils.formatdate(last_modified, False, True)
+
+    resp = requests.request('GET', url, headers=headers, timeout=timeout, parse_headers=True)
+
+    if resp.status_code == 304:
+        # Not modified
+        resp.close()
+        return None
+    if resp.status_code != 200:
+        resp.close()
+        raise Exception()
+
+    content = resp.content
+
+    last_modified = resp.get_date_as_int('last-modified') or 0
+
+    return (last_modified, content)
+
+
+def fetch_zone(zone_name, last_modified, timeout=None):
+    url = server_url + dataset + '/zones-minitzif/' + zone_name
+    return fetch(url, last_modified, timeout)
+
+
+def fetch_all(last_modified, timeout=None):
+    url = server_url + dataset + '/minitzdb'
+    return fetch(url, last_modified, timeout)
+
+
+def fetch_names(last_modified, timeout=None):
+    url = server_url + 'zone-names-mini'
+    return fetch(url, last_modified, timeout)
diff --git a/micropython/minitz/minitz/persist.py b/micropython/minitz/minitz/persist.py
new file mode 100644
index 000000000..8d428324e
--- /dev/null
+++ b/micropython/minitz/minitz/persist.py
@@ -0,0 +1,231 @@
+import datetime
+import os
+import struct
+import time
+from . import Database, tzwrap
+from .fetch import fetch_zone, fetch_all
+
+_local_zone_name = 'UTC'
+_whole_db = False
+
+_db = None
+_last_modified = None
+_local_zone = None
+_local_tzinfo = datetime.timezone.utc
+
+_last_check = None
+
+path_for_meta_file = 'tzmeta'
+path_for_db_file = 'tzdata'
+
+# Initialise by reading from persistent storage.
+def init(want_zone_name=None, want_whole_db=None):
+    try:
+        with open(path_for_meta_file, 'rb') as fp:
+            last_modified, data_crc = struct.unpack('<QI', fp.read(12))
+            local_zone_name = fp.read().decode()
+
+        if not local_zone_name:
+            # Corrupt file:
+            # Mode should be 1 or 2.
+            # Zone name is mandatory.
+            raise ValueError()
+        if last_modified == 0:
+            last_modified = None
+
+        with open(path_for_db_file, 'rb') as fp:
+            data = fp.read()
+        
+        db = Database(data)
+        if db.kind != 1 and db.kind != 2:
+            raise ValueError()
+        if db.crc != data_crc:
+            # The tzdata and tzmeta files do not match.
+            raise ValueError()
+
+        whole_db = (db.kind == 2)
+        if want_whole_db is not None and want_whole_db != whole_db:
+            # Want to download one zone file only, have whole DB
+            # OR want to download whole DB, only have one zone
+            raise ValueError()
+
+        if want_zone_name is not None and want_zone_name != local_zone_name:
+            if not whole_db:
+                # Need to download correct zone file.
+                raise ValueError()
+            local_zone_name = want_zone_name
+
+        # For a TZIF file, the string passed to get_zone_by_name() is ignored.
+        local_zone = db.get_zone_by_name(local_zone_name)
+        local_tzinfo = tzwrap(local_zone)
+
+        # Success.
+        success = True
+    except:
+        # Failed
+        success = False
+        
+        db = None
+        last_modified = None
+        local_zone_name = want_zone_name or 'UTC'
+        whole_db = whole_db or False
+        local_zone = None
+        local_tzinfo = datetime.timezone.utc if local_zone_name == 'UTC' else None
+    
+    # Save state.
+    global _local_zone_name, _whole_db, _db, _last_modified, _local_zone, _local_tzinfo, _last_check
+    _local_zone_name = local_zone_name
+    _whole_db = whole_db
+    _db = db
+    _last_modified = last_modified
+    _local_zone = local_zone
+    _local_tzinfo = local_tzinfo
+    _last_check = None
+    if success:
+        # Pretend last check was 23.5 hours ago.
+        # That way the next check will be in 30 minutes.
+        # This means that if there are many reboots in a row, we don't flood
+        # the server with requests.
+        # 23.5 * 3600 * 1000 = 84_600_000
+        #
+        # (It would be better if we could use real UTC time to track when the
+        # last check was, and store the last update time in persistent memory.
+        # But we don't necessarily know the real UTC time at init time, and may
+        # not want a Flash write on every update check).
+        _last_check = time.ticks_add(time.ticks_ms(), -84_600_000)
+
+    return success
+
+
+def _force_update_from_internet(zone_name=None, whole_db=None, timeout=None):
+    last_modified = _last_modified
+    if whole_db is None:
+        whole_db = _whole_db
+    elif whole_db != _whole_db:
+        # Force fresh download as it's a different file
+        last_modified = None
+    if zone_name is None:
+        zone_name = _local_zone_name
+    elif zone_name != _local_zone_name and not whole_db:
+        # Force fresh download as it's a different file
+        last_modified = None
+    if not zone_name:
+        # Empty string is not a valid zone name.
+        raise ValueError()
+
+    # We update _last_check even if the HTTP request fails.
+    # This is to comply with the fair usage policy of tzdata.net.
+    global _last_check
+    _last_check = time.ticks_ms()
+
+    if whole_db:
+        last_modified, data = fetch_zone(zone_name, last_modified, timeout)
+    else:
+        last_modified, data = fetch_all(last_modified, timeout)
+
+    if data is None:
+        # Not changed
+        return
+
+    db = Database(data)
+    if db.kind != (2 if whole_db else 1):
+        # Not the kind of file that was expected
+        raise ValueError()
+
+    # For a TZIF file, the string passed to get_zone_by_name() is ignored.
+    local_zone = db.get_zone_by_name(zone_name)
+    local_tzinfo = tzwrap(local_zone)
+
+    # Download success!
+
+    # Save state.
+    global _local_zone_name, _whole_db, _db, _last_modified, _local_zone, _local_tzinfo
+    _local_zone_name = zone_name
+    _whole_db = whole_db
+    _db = db
+    _last_modified = last_modified
+    _local_zone = local_zone
+    _local_tzinfo = local_tzinfo
+
+    # Save the data to persistent storage.
+
+    # Maybe this may make flash wear-levelling easier?
+    # We give the filesystem as much free space as possible
+    # before we start writing to it.
+    os.unlink(path_for_db_file)
+
+    with open(path_for_meta_file, 'wb') as fp:
+        fp.write(struct.pack('<QI', last_modified or 0, db.crc))
+        fp.write(zone_name.encode())
+
+    with open(path_for_db_file, 'wb') as fp:
+        fp.write(data)
+
+
+# Initialise by reading from persistent storage.
+# If that fails, will try to do first-time download of timezone
+# data from the Internet.
+def init_with_download_if_needed(zone_name=None, whole_db=None, timeout=None):
+    if not init(zone_name, whole_db):
+        if whole_db or zone_name != 'UTC':
+            _force_update_from_internet(zone_name, whole_db, timeout)
+
+
+def set_zone(zone_name, can_download, timeout=None):
+    if _local_zone_name == zone_name:
+        # Nothing to do!
+        pass
+    elif _whole_db:
+        local_zone = _db.get_zone_by_name(zone_name)
+        local_tzinfo = tzwrap(local_zone)
+            
+        global _local_zone_name, _local_zone, _local_tzinfo
+        _local_zone_name = zone_name
+        _local_zone = local_zone
+        _local_tzinfo = local_tzinfo
+    elif not can_download:
+        raise ValueError("Changing zone without whole DB or Internet")
+    else:
+        _force_update_from_internet(zone_name, _whole_db, timeout)
+
+
+def update_from_internet_if_needed(timeout=None):
+    # Call this regularly.  Ideally at least once an hour, but it's fine
+    # to call it much more frequently, even multiple times per second.
+    # This function will do nothing if an update is not needed.
+    #
+    # We attempt an Internet update at most once per day.
+    # This is to comply with the fair usage policy of tzdata.net.
+    if (_last_check is not None and
+        time.ticks_diff(time.ticks_ms(), _last_check) < 24 * 3600 * 1000):
+        # Too soon.
+        return
+    _force_update_from_internet(timeout=timeout)
+
+
+def has_tzinfo():
+    return _local_tzinfo is not None
+
+def get_tzinfo():
+    if _local_tzinfo is None:
+        raise ValueError()
+    return _local_tzinfo
+
+def have_db():
+    return _db is not None
+
+def get_raw_zone():
+    if _local_zone is None:
+        raise ValueError()
+    return _local_zone
+
+def get_db():
+    if _db is None:
+        raise ValueError()
+    return _db
+
+def get_zone_name():
+    return _local_zone_name
+
+def get_last_modified():
+    return datetime.datetime.fromtimestamp(_last_modified, datetime.timezone.utc)
diff --git a/micropython/net/ntptime/ntptime.py b/micropython/net/ntptime/ntptime.py
index ff0d9d202..25cc62ad1 100644
--- a/micropython/net/ntptime/ntptime.py
+++ b/micropython/net/ntptime/ntptime.py
@@ -22,12 +22,37 @@ def time():
     s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
     try:
         s.settimeout(timeout)
-        res = s.sendto(NTP_QUERY, addr)
+        s.sendto(NTP_QUERY, addr)
         msg = s.recv(48)
     finally:
         s.close()
     val = struct.unpack("!I", msg[40:44])[0]
 
+    # 2024-01-01 00:00:00 converted to an NTP timestamp
+    MIN_NTP_TIMESTAMP = 3913056000
+
+    # Y2036 fix
+    #
+    # The NTP timestamp has a 32-bit count of seconds, which will wrap back
+    # to zero on 7 Feb 2036 at 06:28:16.
+    #
+    # We know that this software was written during 2024 (or later).
+    # So we know that timestamps less than MIN_NTP_TIMESTAMP are impossible.
+    # So if the timestamp is less than MIN_NTP_TIMESTAMP, that probably means
+    # that the NTP time wrapped at 2^32 seconds.  (Or someone set the wrong
+    # time on their NTP server, but we can't really do anything about that).
+    #
+    # So in that case, we need to add in those extra 2^32 seconds, to get the
+    # correct timestamp.
+    #
+    # This means that this code will work until the year 2160.  More precisely,
+    # this code will not work after 7th Feb 2160 at 06:28:15.
+    #
+    if val < MIN_NTP_TIMESTAMP:
+        val += 0x100000000
+
+    # Convert timestamp from NTP format to our internal format
+
     EPOCH_YEAR = utime.gmtime(0)[0]
     if EPOCH_YEAR == 2000:
         # (date(2000, 1, 1) - date(1900, 1, 1)).days * 24*60*60
diff --git a/python-ecosys/requests/requests/__init__.py b/python-ecosys/requests/requests/__init__.py
index 740102916..9414be3e8 100644
--- a/python-ecosys/requests/requests/__init__.py
+++ b/python-ecosys/requests/requests/__init__.py
@@ -1,6 +1,16 @@
 import usocket
 
 
+def _make_user_agent():
+    import sys
+    comment = sys.implementation._machine
+    comment = comment.replace('\\', '\\\\').replace('(', '\\(').replace(')', '\\)')
+    ua = "MicroPython/{}.{}.{} ({})".format(*sys.implementation[1][:3], comment)
+    return ua
+
+default_user_agent = _make_user_agent()
+
+
 class Response:
     def __init__(self, f):
         self.raw = f
@@ -32,6 +42,29 @@ def json(self):
 
         return ujson.loads(self.content)
 
+    def get_date(self, k):
+        import email.utils
+        as_str = self.headers.get(k)
+        if not as_str:
+            return None
+        return email.utils.parsedate_to_datetime(as_str)
+
+    def get_date_as_int(self, k):
+        import datetime
+        dt = self.get_date(k)
+        if dt is None:
+            return None
+        if dt.tzinfo is None:
+            dt = dt.replace(tzinfo=datetime.timezone.utc)
+        else:
+            dt = dt.astimezone(datetime.timezone.utc)
+
+        # dt.timestamp() returns a value of type float, but the number
+        # inside that float will always be an integer in this case,
+        # because the formats supported by parsedate_to_datetime()
+        # only have 1 second resolution.
+        return int(dt.timestamp())
+
 
 def request(
     method,
@@ -43,17 +76,39 @@ def request(
     auth=None,
     timeout=None,
     parse_headers=True,
+    user_agent=True
 ):
     redirect = None  # redirection url, None means no redirection
     chunked_data = data and getattr(data, "__next__", None) and not getattr(data, "__len__", None)
 
+    # Security and correctness: Can't add extra fields to `headers`,
+    # we have to have separate variables for them.
+    #
+    # This is because have `headers={}` in the function prototype.
+    # So the same dictionary will get reused for every call that doesn't
+    # explicitly specify a `headers` parameter.
+    #
+    # So if we put an Authorization header in `headers`, then following a
+    # request to a site that requires a username and password, that same
+    # plain-text username and password would be sent with every following HTTP
+    # request that used the default for the `headers` parameter.
+    basic_auth_header = None
     if auth is not None:
         import ubinascii
-
         username, password = auth
-        formated = b"{}:{}".format(username, password)
-        formated = str(ubinascii.b2a_base64(formated)[:-1], "ascii")
-        headers["Authorization"] = "Basic {}".format(formated)
+        basic_auth_header = b"{}:{}".format(username, password)
+        basic_auth_header = ubinascii.b2a_base64(basic_auth_header)[:-1]
+
+    if user_agent:
+        if user_agent is True:
+            # Use the standard User-Agent
+            user_agent = default_user_agent
+        elif user_agent[0] == ' ':
+            # It's a suffix to append to the standard User-Agent
+            user_agent = default_user_agent + user_agent
+
+        if user_agent:
+            user_agent = user_agent.encode('ascii')
 
     try:
         proto, dummy, host, path = url.split("/", 3)
@@ -91,11 +146,31 @@ def request(
         s.connect(ai[-1])
         if proto == "https:":
             context = tls.SSLContext(tls.PROTOCOL_TLS_CLIENT)
+            # TODO: This is a security vulnerability.
+            # HTTPS is providing nearly zero security, because of the next
+            # line.  We disable all the protection against MiTM attacks!
+            #
+            # I mean... with this configuration, HTTPS still provides
+            # protection against passive eavesdropping, so there's that?
+            # But with modern network design, and modern attacks, anyone
+            # able to passively eavesdrop is almost certainly able to MiTM
+            # too.  So the safety level is technically not quite zero, but
+            # it is very close to zero, and is far less than people using
+            # HTTPS expect.
             context.verify_mode = tls.CERT_NONE
             s = context.wrap_socket(s, server_hostname=host)
         s.write(b"%s /%s HTTP/1.0\r\n" % (method, path))
         if "Host" not in headers:
             s.write(b"Host: %s\r\n" % host)
+        if basic_auth_header:
+            s.write(b"Authorization: Basic ")
+            s.write(basic_auth_header)
+            s.write(b"\r\n")
+        if user_agent:
+            s.write(b"User-Agent: ")
+            s.write(user_agent)
+            s.write(b"\r\n")
+
         # Iterate over keys to avoid tuple alloc
         for k in headers:
             s.write(k)
@@ -152,7 +227,10 @@ def request(
             elif parse_headers is True:
                 l = str(l, "utf-8")
                 k, v = l.split(":", 1)
-                resp_d[k] = v.strip()
+                # Headers are case insensitive, so we lowercase them.
+                # This avoids having to do a linear case-insensitive search
+                # through the dictionary later.
+                resp_d[k.lower()] = v.strip()
             else:
                 parse_headers(l, resp_d)
     except OSError:
diff --git a/python-stdlib/datetime/datetime.py b/python-stdlib/datetime/datetime.py
index 0f2a89105..d26caa218 100644
--- a/python-stdlib/datetime/datetime.py
+++ b/python-stdlib/datetime/datetime.py
@@ -831,6 +831,7 @@ def timetuple(self):
             conv = _tmod.gmtime
             epoch = datetime.EPOCH.replace(tzinfo=None)
         else:
+            # TODO this is wrong when a timezone is set
             conv = _tmod.localtime
             epoch = datetime.EPOCH
         return conv(round((self - epoch).total_seconds()))