Skip to content

Commit bc5bf2a

Browse files
committed
new command: rtc
1 parent 4899724 commit bc5bf2a

7 files changed

Lines changed: 311 additions & 6 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,24 @@ $ mpytool edit :newfile.py # create new file if doesn't exist
233233
Downloads file to a temp file, opens in editor, uploads back if changed.
234234
Editor priority: `--editor` > `$VISUAL` > `$EDITOR` > error.
235235

236+
### Device RTC (real-time clock)
237+
```
238+
$ mpytool rtc # display current RTC
239+
2026-02-21 15:30:45
240+
241+
$ mpytool rtc --set # set RTC to local PC time
242+
RTC set to 2026-02-21 15:30:45 (local)
243+
244+
$ mpytool rtc --utc # set RTC to UTC time
245+
RTC set to 2026-02-21 14:30:45 (UTC)
246+
247+
$ mpytool rtc "2026-02-21 14:30:00" # set RTC to specific datetime
248+
RTC set to 2026-02-21 14:30:00
249+
```
250+
251+
Flags: `-s`/`--set` and `-l`/`--local` both set local time, `-u`/`--utc` sets UTC.
252+
Manual datetime format: `YYYY-MM-DD HH:MM:SS`.
253+
236254
### Mount local directory on device
237255
```
238256
$ mpytool mount ./src # mount ./src as /remote, auto-start REPL

README_mpremote.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Detailed comparison between [mpytool](https://github.com/pavelrevak/mpytool) and
3535
| Unmount VFS | 🔴 exit session | 🟢 `umount` |
3636
| Virtual submount | 🟢 `ln ./src :/dst` | 🔴 |
3737
| Package install | 🔴 | 🟢 `mip install pkg` |
38-
| RTC control | 🔴 | 🟢 `rtc`, `rtc --set` |
38+
| RTC control | 🟢 `rtc`, `rtc --set` | 🟢 `rtc`, `rtc --set` |
3939
| ROMFS manage | 🔴 | 🟢 `romfs` |
4040
| Edit remote file | 🟢 `edit :file` | 🟢 `edit :file` |
4141
| Flash read/write/ota | 🟢 `flash r/w/erase/ota` | 🔴 |

completions/_mpytool

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,11 @@ _mpytool() {
278278
fi
279279
[[ $nargs -ge 1 ]] && compadd -- '--'
280280
;;
281+
rtc)
282+
# rtc [-s|-l|-u] [datetime]
283+
_mpytool_options rtc
284+
compadd -- '--'
285+
;;
281286
pwd)
282287
# No arguments, -- immediately
283288
compadd -- '--'

completions/mpytool.bash

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,10 @@ _mpytool() {
289289
fi
290290
[[ $nargs -ge 1 && ( -z "$cur" || "--" == "$cur"* ) ]] && COMPREPLY+=("--")
291291
;;
292+
rtc)
293+
# rtc [-s|-l|-u] [datetime]
294+
COMPREPLY=($(compgen -W "$(_mpytool_get_options rtc) --" -- "$cur"))
295+
;;
292296
pwd)
293297
# No arguments, -- immediately
294298
[[ -z "$cur" || "--" == "$cur"* ]] && COMPREPLY+=("--")

mpytool/mpytool.py

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""MicroPython tool"""
22

33
import argparse as _argparse
4+
import datetime as _datetime
45
import fnmatch as _fnmatch
56
import importlib.metadata as _metadata
67
import os as _os
@@ -31,7 +32,7 @@
3132
_CMD_ORDER = [
3233
'ls', 'tree', 'cat', 'cp', 'mv', 'mkdir', 'rm', 'pwd', 'cd', 'path',
3334
'stop', 'reset', 'monitor', 'repl', 'exec', 'run', 'edit', 'info',
34-
'flash', 'mount', 'ln', 'speedtest', 'sleep',
35+
'rtc', 'flash', 'mount', 'ln', 'speedtest', 'sleep',
3536
]
3637

3738

@@ -147,12 +148,12 @@ def _make_parser(method):
147148
for group_opts in getattr(method, '_cmd_groups', []):
148149
group = parser.add_mutually_exclusive_group()
149150
for opt_args, opt_kwargs in getattr(method, '_cmd_options', []):
150-
# Match by first option string (e.g., '--machine')
151-
if opt_args and opt_args[0] in group_opts:
151+
# Match any option string (e.g., '-s' or '--set')
152+
if opt_args and any(opt in group_opts for opt in opt_args):
152153
group.add_argument(*opt_args, **opt_kwargs)
153154
# Add non-grouped options
154155
for opt_args, opt_kwargs in getattr(method, '_cmd_options', []):
155-
if not opt_args or opt_args[0] not in grouped_opts:
156+
if not opt_args or not any(opt in grouped_opts for opt in opt_args):
156157
parser.add_argument(*opt_args, **opt_kwargs)
157158
# Add positional arguments
158159
for args, kwargs in getattr(method, '_cmd_args', []):
@@ -957,6 +958,63 @@ def _dispatch_edit(self, commands, is_last_group):
957958
path = _parse_device_path(args.path, 'edit')
958959
self.cmd_edit(path, editor=args.editor)
959960

961+
@command('rtc', 'Get or set device RTC.')
962+
@mutually_exclusive('--set', '--local', '--utc')
963+
@option('-s', '--set', action='store_true',
964+
help='set to local PC time')
965+
@option('-l', '--local', action='store_true',
966+
help='set to local PC time')
967+
@option('-u', '--utc', action='store_true',
968+
help='set to UTC time')
969+
@argument('datetime', nargs='?', metavar='DATETIME',
970+
help='datetime string (YYYY-MM-DD HH:MM:SS)')
971+
def _dispatch_rtc(self, commands, is_last_group):
972+
args = _make_parser(self._dispatch_rtc).parse_args(commands)
973+
commands.clear()
974+
if args.datetime:
975+
if args.set or args.local or args.utc:
976+
raise ParamsError(
977+
'datetime argument is incompatible with --set/--local/--utc')
978+
try:
979+
parsed = _datetime.datetime.strptime(
980+
args.datetime, '%Y-%m-%d %H:%M:%S')
981+
except ValueError:
982+
raise ParamsError(
983+
'invalid datetime format (use YYYY-MM-DD HH:MM:SS)')
984+
self._set_rtc(parsed)
985+
self.verbose(
986+
f"RTC set to {parsed.strftime('%Y-%m-%d %H:%M:%S')}", 1)
987+
elif args.set or args.local:
988+
now = _datetime.datetime.now()
989+
self._set_rtc(now)
990+
self.verbose(
991+
f"RTC set to {now.strftime('%Y-%m-%d %H:%M:%S')} (local)", 1)
992+
elif args.utc:
993+
now = _datetime.datetime.now(_datetime.timezone.utc)
994+
self._set_rtc(now)
995+
self.verbose(
996+
f"RTC set to {now.strftime('%Y-%m-%d %H:%M:%S')} (UTC)", 1)
997+
else:
998+
rtc = self._get_rtc()
999+
print(
1000+
f"{rtc[0]:04d}-{rtc[1]:02d}-{rtc[2]:02d} "
1001+
f"{rtc[4]:02d}:{rtc[5]:02d}:{rtc[6]:02d}")
1002+
1003+
def _set_rtc(self, dt_obj):
1004+
"""Set device RTC from datetime object"""
1005+
timetuple = (
1006+
dt_obj.year, dt_obj.month, dt_obj.day, dt_obj.weekday(),
1007+
dt_obj.hour, dt_obj.minute, dt_obj.second, dt_obj.microsecond)
1008+
self.mpy.comm.exec(
1009+
f"__import__('machine').RTC().datetime({timetuple})")
1010+
1011+
def _get_rtc(self):
1012+
"""Get device RTC as tuple"""
1013+
import ast
1014+
result = self.mpy.comm.exec(
1015+
"print(__import__('machine').RTC().datetime())")
1016+
return ast.literal_eval(result.decode().strip())
1017+
9601018
@command('info', 'Show device info (platform, memory, filesystem).')
9611019
def _dispatch_info(self, commands, is_last_group):
9621020
_, commands[:] = _make_parser(self._dispatch_info).parse_known_args(
@@ -1274,7 +1332,7 @@ def _dispatch_args(self, commands, is_last_group):
12741332
_COMMANDS = frozenset({
12751333
'ls', 'tree', 'cat', 'mkdir', 'rm', 'pwd', 'cd', 'path',
12761334
'reset', 'stop', 'monitor', 'repl', 'exec', 'run', 'edit', 'info',
1277-
'flash', 'sleep', 'cp', 'mv', 'mount', 'ln', 'speedtest',
1335+
'rtc', 'flash', 'sleep', 'cp', 'mv', 'mount', 'ln', 'speedtest',
12781336
'_paths', '_ports', '_commands', '_options', '_args',
12791337
})
12801338

tests/test_integration.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2687,5 +2687,78 @@ def test_10_append_multiple_paths(self):
26872687
self.assertEqual(path[-2:], ['/a', '/b'])
26882688

26892689

2690+
@requires_device
2691+
class TestRtc(unittest.TestCase):
2692+
"""Test RTC operations"""
2693+
2694+
@classmethod
2695+
def setUpClass(cls):
2696+
from mpytool import ConnSerial, Mpy
2697+
from mpytool.mpytool import MpyTool
2698+
from mpytool.logger import SimpleColorLogger
2699+
cls.conn = ConnSerial(port=DEVICE_PORT, baudrate=115200)
2700+
cls.mpy = Mpy(cls.conn)
2701+
cls.log = SimpleColorLogger(loglevel=0, verbose_level=0)
2702+
cls.tool = MpyTool(cls.conn, log=cls.log, verbose=None)
2703+
cls.tool._mpy = cls.mpy
2704+
2705+
@classmethod
2706+
def tearDownClass(cls):
2707+
cls.mpy.comm.exit_raw_repl()
2708+
cls.conn.close()
2709+
2710+
def test_01_rtc_display(self):
2711+
"""Test RTC display returns valid datetime tuple"""
2712+
rtc = self.tool._get_rtc()
2713+
self.assertEqual(len(rtc), 8)
2714+
# Year should be reasonable (2000 is default, up to 2100)
2715+
self.assertGreaterEqual(rtc[0], 2000)
2716+
self.assertLessEqual(rtc[0], 2100)
2717+
# Month 1-12
2718+
self.assertGreaterEqual(rtc[1], 1)
2719+
self.assertLessEqual(rtc[1], 12)
2720+
# Day 1-31
2721+
self.assertGreaterEqual(rtc[2], 1)
2722+
self.assertLessEqual(rtc[2], 31)
2723+
2724+
def test_02_rtc_set_local(self):
2725+
"""Test setting RTC to local time"""
2726+
import datetime
2727+
now = datetime.datetime.now()
2728+
self.tool._set_rtc(now)
2729+
rtc = self.tool._get_rtc()
2730+
# Year, month, day should match
2731+
self.assertEqual(rtc[0], now.year)
2732+
self.assertEqual(rtc[1], now.month)
2733+
self.assertEqual(rtc[2], now.day)
2734+
# Hour, minute should be close (within 1 minute tolerance)
2735+
self.assertEqual(rtc[4], now.hour)
2736+
self.assertAlmostEqual(rtc[5], now.minute, delta=1)
2737+
2738+
def test_03_rtc_set_utc(self):
2739+
"""Test setting RTC to UTC time"""
2740+
import datetime
2741+
utc_now = datetime.datetime.now(datetime.timezone.utc)
2742+
self.tool._set_rtc(utc_now)
2743+
rtc = self.tool._get_rtc()
2744+
self.assertEqual(rtc[0], utc_now.year)
2745+
self.assertEqual(rtc[1], utc_now.month)
2746+
self.assertEqual(rtc[2], utc_now.day)
2747+
self.assertEqual(rtc[4], utc_now.hour)
2748+
2749+
def test_04_rtc_set_manual(self):
2750+
"""Test setting RTC to manual datetime"""
2751+
import datetime
2752+
test_dt = datetime.datetime(2025, 6, 15, 12, 30, 45)
2753+
self.tool._set_rtc(test_dt)
2754+
rtc = self.tool._get_rtc()
2755+
self.assertEqual(rtc[0], 2025)
2756+
self.assertEqual(rtc[1], 6)
2757+
self.assertEqual(rtc[2], 15)
2758+
self.assertEqual(rtc[4], 12)
2759+
self.assertEqual(rtc[5], 30)
2760+
self.assertEqual(rtc[6], 45)
2761+
2762+
26902763
if __name__ == "__main__":
26912764
unittest.main()

tests/test_mpytool.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,5 +813,152 @@ def test_path_unknown_flag(self):
813813
self.assertIn('-x', str(ctx.exception))
814814

815815

816+
class TestRtcCommand(unittest.TestCase):
817+
"""Tests for rtc command"""
818+
819+
def setUp(self):
820+
self.mock_conn = Mock()
821+
self.tool = MpyTool(self.mock_conn, verbose=None)
822+
self.tool._mpy = Mock()
823+
self.tool._mpy.comm = Mock()
824+
825+
def test_rtc_display(self):
826+
"""'rtc' displays current RTC"""
827+
# RTC tuple: (year, month, day, weekday, hour, min, sec, subsec)
828+
self.tool._mpy.comm.exec.return_value = b"(2026, 2, 21, 5, 14, 30, 45, 0)\n"
829+
with patch('builtins.print') as mock_print:
830+
commands = []
831+
self.tool._dispatch_rtc(commands, False)
832+
mock_print.assert_called_once_with("2026-02-21 14:30:45")
833+
834+
def test_rtc_set_local(self):
835+
"""'rtc --set' sets RTC to local time"""
836+
self.tool._mpy.comm.exec.return_value = b""
837+
with patch('mpytool.mpytool._datetime') as mock_dt:
838+
mock_now = Mock()
839+
mock_now.year = 2026
840+
mock_now.month = 2
841+
mock_now.day = 21
842+
mock_now.weekday.return_value = 5
843+
mock_now.hour = 14
844+
mock_now.minute = 30
845+
mock_now.second = 45
846+
mock_now.microsecond = 0
847+
mock_now.strftime.return_value = "2026-02-21 14:30:45"
848+
mock_dt.datetime.now.return_value = mock_now
849+
commands = ['--set']
850+
self.tool._dispatch_rtc(commands, False)
851+
# Verify RTC was set
852+
call_args = self.tool._mpy.comm.exec.call_args[0][0]
853+
self.assertIn("RTC().datetime", call_args)
854+
self.assertIn("2026", call_args)
855+
856+
def test_rtc_local_flag(self):
857+
"""'rtc --local' sets RTC to local time"""
858+
self.tool._mpy.comm.exec.return_value = b""
859+
with patch('mpytool.mpytool._datetime') as mock_dt:
860+
mock_now = Mock()
861+
mock_now.year = 2026
862+
mock_now.month = 1
863+
mock_now.day = 15
864+
mock_now.weekday.return_value = 2
865+
mock_now.hour = 10
866+
mock_now.minute = 0
867+
mock_now.second = 0
868+
mock_now.microsecond = 0
869+
mock_now.strftime.return_value = "2026-01-15 10:00:00"
870+
mock_dt.datetime.now.return_value = mock_now
871+
commands = ['--local']
872+
self.tool._dispatch_rtc(commands, False)
873+
call_args = self.tool._mpy.comm.exec.call_args[0][0]
874+
self.assertIn("RTC().datetime", call_args)
875+
876+
def test_rtc_utc_flag(self):
877+
"""'rtc --utc' sets RTC to UTC time"""
878+
self.tool._mpy.comm.exec.return_value = b""
879+
with patch('mpytool.mpytool._datetime') as mock_dt:
880+
mock_utc = Mock()
881+
mock_utc.year = 2026
882+
mock_utc.month = 2
883+
mock_utc.day = 21
884+
mock_utc.weekday.return_value = 5
885+
mock_utc.hour = 13
886+
mock_utc.minute = 30
887+
mock_utc.second = 45
888+
mock_utc.microsecond = 0
889+
mock_utc.strftime.return_value = "2026-02-21 13:30:45"
890+
mock_dt.datetime.now.return_value = mock_utc
891+
mock_dt.timezone.utc = Mock()
892+
commands = ['--utc']
893+
self.tool._dispatch_rtc(commands, False)
894+
# Verify datetime.now was called with UTC timezone
895+
mock_dt.datetime.now.assert_called_with(mock_dt.timezone.utc)
896+
897+
def test_rtc_manual_datetime(self):
898+
"""'rtc "2026-02-21 14:30:00"' sets RTC manually"""
899+
self.tool._mpy.comm.exec.return_value = b""
900+
with patch('mpytool.mpytool._datetime') as mock_dt:
901+
mock_parsed = Mock()
902+
mock_parsed.year = 2026
903+
mock_parsed.month = 2
904+
mock_parsed.day = 21
905+
mock_parsed.weekday.return_value = 5
906+
mock_parsed.hour = 14
907+
mock_parsed.minute = 30
908+
mock_parsed.second = 0
909+
mock_parsed.microsecond = 0
910+
mock_parsed.strftime.return_value = "2026-02-21 14:30:00"
911+
mock_dt.datetime.strptime.return_value = mock_parsed
912+
commands = ['2026-02-21 14:30:00']
913+
self.tool._dispatch_rtc(commands, False)
914+
mock_dt.datetime.strptime.assert_called_with(
915+
'2026-02-21 14:30:00', '%Y-%m-%d %H:%M:%S')
916+
917+
def test_rtc_invalid_datetime_format(self):
918+
"""'rtc "invalid"' raises ParamsError"""
919+
commands = ['invalid']
920+
with self.assertRaises(ParamsError) as ctx:
921+
self.tool._dispatch_rtc(commands, False)
922+
self.assertIn('invalid datetime format', str(ctx.exception))
923+
924+
def test_rtc_datetime_with_flag_error(self):
925+
"""'rtc --set "2026-02-21 14:30:00"' raises ParamsError"""
926+
commands = ['--set', '2026-02-21 14:30:00']
927+
with self.assertRaises(ParamsError) as ctx:
928+
self.tool._dispatch_rtc(commands, False)
929+
self.assertIn('incompatible', str(ctx.exception))
930+
931+
def test_rtc_short_flags(self):
932+
"""Test short flags -s, -l, -u"""
933+
self.tool._mpy.comm.exec.return_value = b""
934+
with patch('mpytool.mpytool._datetime') as mock_dt:
935+
mock_now = Mock()
936+
mock_now.year = 2026
937+
mock_now.month = 1
938+
mock_now.day = 1
939+
mock_now.weekday.return_value = 2
940+
mock_now.hour = 0
941+
mock_now.minute = 0
942+
mock_now.second = 0
943+
mock_now.microsecond = 0
944+
mock_now.strftime.return_value = "2026-01-01 00:00:00"
945+
mock_dt.datetime.now.return_value = mock_now
946+
mock_dt.timezone.utc = Mock()
947+
# Test -s
948+
commands = ['-s']
949+
self.tool._dispatch_rtc(commands, False)
950+
self.assertTrue(self.tool._mpy.comm.exec.called)
951+
self.tool._mpy.comm.exec.reset_mock()
952+
# Test -l
953+
commands = ['-l']
954+
self.tool._dispatch_rtc(commands, False)
955+
self.assertTrue(self.tool._mpy.comm.exec.called)
956+
self.tool._mpy.comm.exec.reset_mock()
957+
# Test -u
958+
commands = ['-u']
959+
self.tool._dispatch_rtc(commands, False)
960+
mock_dt.datetime.now.assert_called_with(mock_dt.timezone.utc)
961+
962+
816963
if __name__ == "__main__":
817964
unittest.main()

0 commit comments

Comments
 (0)