-
Yes, that would be an interesting side-quest. No, I’m not going to try it. ↩
DATETOSECS
—converts a collection of date and time components to a timestamp represented as seconds since epochSECSTODATE
—extracts the date and time components from a timestamp represented as seconds since epoch-
The UTC offset allows notes to be displayed in note-local time, UTC time, or the reader-local time. ↩
December Adventure Day 05
Menus and Dialogs
After the relative successes of the last few days, it’s been a day of taking stock in my December Adventure and focusing exclusively on Thoughts—no ROM uploads today.
I started the day by creating a GitHub repository for Thoughts and committed the current version. Without native support for git on my Psion1, I suspect updates will be a little more sporadic than usual, but I’ll try to push each day’s additions.
Having the Thoughts source on my Mac for the first time since starting, I tried running it through Tom’s new Lua-based OPL compiler and was pleasantly surprised to see it worked perfectly. Perhaps tomorrow I’ll set up automated builds using GitHub Actions.
I also took a little time out over my morning coffee to improve my workflow—switching between running programs is a multi-step process in EPOC32 and I’m constantly bouncing back and forth between my source, OPL reference, and Thoughts itself. Thanks to a suggestion by Fabrice, I settled on nSwitcher which offers a NeXTSTEP-like dock and the Ctrl + Space global shortcut for cycling between programs.
With everything a little tidier, and the basic functionality in place, I went about setting up an application framework on which I can build other features—OPL apps commonly centered around a polling loop, and Thoughts will follow this pattern.
First up, the main:
needs to watch for input events:
PROC main:
GLOBAL CRLF$(2)
LOCAL ev&(16), k&
initglobals:
clear:
WHILE 1
GETEVENT32 ev&()
k& = ev&(1)
IF k& = KKeyMenu% OR k& = KKeySidebarMenu%
menu:
ELSEIF k& < 32
REM Key codes are modified if control is pressed. I don't know why.
k& = k& + %a - 1
IF k& = %n
new:
ELSEIF k& = %e AND ev&(4) = KKModControl%
close:
ELSEIF k& = %k AND ev&(4) = KKModControl%
clear:
ELSEIF k& = %a AND ev&(4) = (KKModControl% OR KKModShift%)
about:
ELSE
PRINT k&
ENDIF
ELSEIF (ev&(1) AND &400) = 0
PRINT ev&(1), ev&(2), ev&(3), ev&(4), ev&(5)
ENDIF
ENDWH
ENDP
Here, I’m using the synchronous, blocking GETEVENT32
call to poll for events. If you look at the event processing though, you’ll notice some pretty grim manipulation of the key code to handle keyboard shortcuts (things like Ctrl + N, etc). I’ve not found a good discussion of why yet, but hopefully I can figure it out over the next few days and update the code with an explanation at the very least; magic numbers make me incredibly nervous.
The original code is now pushed down into the new:
procedure, and I’m able to add the other functionality.
A main menu:
PROC menu:
LOCAL m%
mINIT
mCARD "File", "New", -%n, "Close", %e
mCARD "View", "Clear", %k
mCARD "Tools", "About Thoughts...", %A
m% = MENU
IF m% = %n
new:
ELSEIF m% = %e
close:
ELSEIF m% = %k
clear:
ELSEIF m% = %A
about:
ENDIF
ENDP
And an about screen:
PROC about:
dINIT "About Thoughts"
dTEXT "", "Copyright " + CHR$(169) + " Jason Morley 2024"
dBUTTONS "OK", 13 + $100
DIALOG
ENDP
Thankfully OPL provides dedicated structures and calls for presenting UI like menus and dialogs, making it incredibly easy to create programs with a native look and feel.
As Thoughts is starting to take shape, I find myself wanting to add some 90s-era graphics to give it character. I also need to add global hot key support, and build an installable app. Lots to keep me entertained.
December Adventure Day 04
It's Alive!
It’s been a successful fourth day of my December Adventure—there are a few too many threads but I feel like I’m making progress: I went on a tiny side-quest to fix the way tables are displayed on my website, uploaded a few more ROMs, and continued to bash my head at OPL.
Just like yesterday, I find myself incredibly grateful for the on-going interest and support others have given me—both Fabrice and Tom kept me on-track with my OPL pursuits and Alex gave me a couple of pointers about the Psion Workabout.
ROMs
You know the drill at this point: I added some new ROMs to the Psion-ROM repository—Workabout and Workabout MX this time around.
The Workabout devices are rugged, industrial handhelds, intended for use in warehouse environments. They run seemingly fully-featured EPOC16 and have absolutely tiny screens making for perhaps the cutest rendition of the operating system I’ve seen.
Psion Workabout
Device | EPOC16 Version | ROM Version | Language | Filename | MD5 Checksum |
---|---|---|---|---|---|
Psion Workabout | 3.56f | 0.24b | English | w1_v0.24b.bin | 1afac14fe87e19e7d29d494177dc58d9 |
Psion Workabout | 3.56f | 1.00f | English | w1_v1.00f.bin | 87c84a27bc71df5e19ac1208735a7a1e |
Psion Workabout | 3.96f | 2.40f | English | w1_v2.40f.bin | 10b9a0c9174aec0316571827dce42013 |
Psion Workabout MX
Device | EPOC16 Version | ROM Version | Language | Filename | MD5 Checksum |
---|---|---|---|---|---|
Psion Workabout MX | 4.31f | 7.20f | English | w2mx_v7.20f.bin | d5e5c2aa32f9888e7fec8d2214f1547e |
Unfortunately I didn’t get around to adding the new Series 7 ROM dump to the repository. Tomorrow, I hope. 🤞🏻
Thoughts-Lite
Thanks to input from both Tom and Fabrice, the EPOC version of Thoughts feels like it made massive progress today as I was finally able to put everything I’ve worked on over the last few days into an end-to-end demo.
I ended yesterday stuck trying to apply the long representation of the UTC offset returned by SIUTCOffset&:
to the date/time returned by DTNOW&:
. I had incorrectly assumed that the date/time would be conveniently represented as seconds since epoch (1970-01-01) allowing me to simply subtract the UTC offset. Instead, it turns out all Date.opx operations (functions prefixed by DT
) return an opaque pointer to an underlying date/time object which you can only interact with through dedicated functions.
Consider the following code:
timestamp&=DTNOW&:
offset&=SIUTCOffset&:
year&=DTYEAR&:(timestamp&-offset&)
This fails because, although timestamp&
is a long, it is actually a pointer, so subtracting offset&
results in a pointer to goodness knows where and the call to DTYEAR&:
ends up operating on random memory.
Finding a suitable API to work around this proved challenging. Out of the box, OPL provides the following functions:
These both look incredibly helpful and would seem to imply there’s an easy way to get the current time as seconds since epoch. Unfortunately that’s not the case: the language provides DATIM$
which, ‘returns the current date and time from the system clock as a string’, but seemingly nothing that returns seconds since epoch. In fact, it looks like the best you can do with the native OPL API is:
LOCAL timestamp&
timestamp& = DATETOSECS(YEAR, MONTH, DAY, HOUR, MINUTE, SECOND)
While it might not be immediately obvious, YEAR
, MONTH
, DAY
, HOUR
, MINUTE
, and SECOND
are all function calls, meaning this code is incredibly susceptible to race conditions and is likely to lead to some wacky results—consider what happens, for example, if the minute rolls-over before the SECONDS
call is performed.
Instead of using this mildly terrifying approach, and thanks to a suggestion from Tom, I settled on using DTSECSDIFF&:
from Date.opx which allows me to calculate the number of seconds between two dates:
PROC NOW&:
REM Return the current time as seconds since epoch.
LOCAL epoch&, now&, result&
epoch&=DTNEWDATETIME&:(1970,1,1,0,0,0,0)
now&=DTNOW&:
result&=DTSECSDIFF&:(epoch&,now&)
DTDELETEDATETIME:(epoch&)
DTDELETEDATETIME:(now&)
RETURN result&
ENDP
I have to manually define epoch&
, but this provides a robust way to get the current date/time as seconds since epoch and allows me to avoid Date.opx’s quirky opaque date/time objects wherever possible.
This approach necessitates an update to ISO8601$:
which is (fortunately) significantly simplified as I can now take advantage of SECSTODATE
:
PROC ISO8601$:(timestamp&, utcOffset&)
REM Return an ISO 8601 formatted date and time with UTC offset.
REM Result is guaranteed to be 25 characters long.
LOCAL year%, month%, day%, hour%, minute%, second%, yearday%
LOCAL offsetSign$(1), offsetHours&, offsetMinutes&
REM Extract the timestamp components.
SECSTODATE timestamp&, year%, month%, day%, hour%, minute%, second%, yearday%
REM Extract the offset components.
offsetHours& = IABS(utcOffset& / 3600)
offsetMinutes& = MOD&:(utcOffset& / 60, 60)
IF (utcOffset& < 0)
offsetSign$="-"
ELSE
offsetSign$="+"
ENDIF
RETURN NUM$(year%, 4) + "-" + ZPAD$:(month%, 2) + "-" + ZPAD$:(day%, 2) + "T" + ZPAD$:(hour%, 2) + ":" + ZPAD$:(minute%, 2) + ":" + ZPAD$:(second%, 2) + offsetSign$ + ZPAD$:(offsetHours&, 2) + ":" + ZPAD$:(offsetMinutes&, 2)
ENDP
It also allows me to generate my UTC filenames in a very similar way and just apply the UTC offset by subtracting it:
PROC BASENAME$:(timestamp&, utcOffset&)
REM Return a string representation of timestamp& and utcOffset& suitable for using in a filename.
REM Result is guaranteed to be 19 characters long.
LOCAL utcTimestamp&
LOCAL year%, month%, day%, hour%, minute%, second%, yearday%
utcTimestamp&=timestamp&-utcOffset&
SECSTODATE utcTimestamp&, year%, month%, day%, hour%, minute%, second%, yearday%
RETURN NUM$(year%, 4) + "-" + ZPAD$:(month%, 2) + "-" + ZPAD$:(day%, 2) + "-" + ZPAD$:(hour%, 2) + "-" + ZPAD$:(minute%, 2) + "-" + ZPAD$:(second%, 2)
ENDP
With all this nuanced code in place, actually assembling a Frontmatter header, writing it to a timestamped file, and launching Editor—the bulk of the functionality—seemed oddly simple (albeit a little bloated):
PROC main:
LOCAL k&
LOCAL timestamp&, offset&, date$(25)
LOCAL basename$(22), path$(255)
LOCAL content$(255)
LOCAL handle%
LOCAL r%
REM Get the current timestamp and offset.
timestamp&=NOW&:
offset&=SIUTCOffset&:
REM Format the date, path, and content.
date$ = ISO8601$:(timestamp&, offset&)
basename$ = BASENAME$:(timestamp&, offset&)
path$ = "C:\Thoughts\" + basename$ + ".md"
content$ = "---" + CRLF$: + "date: " + date$ + CRLF$: + "---" + REPT$(CRLF$:, 2)
REM Write the metadata to the file.
r% = IOOPEN(handle%, path$, 1)
IF (r% <> 0)
REM TODO: Present the error.
PRINT "Failed to open file with error " + NUM$(r%, 2) + "."
GET
RETURN
ENDIF
REM TODO: Check the number of bytes written.
IOWRITE(handle%, ADDR(content$) + 1, LEN(content$))
r% = IOCLOSE(handle%)
IF (r% <> 0)
REM TODO: Present the error.
PRINT "Failed to close file with error " + NUM$(r%,2) + "."
GET
RETURN
ENDIF
REM Open the file.
k& = RUNAPP&:("Editor", path$, "", 2)
REM GET
ENDP
Now, running my newly translated ‘Thoughts.opo’ will open a new timestamped file in Editor, ready for me to type my notes:
Tomorrow, I plan to add support for launching Thoughts from one of the Series 7’s silkscreen buttons and implementing a global hotkey for devices like the Series 5 and Revo. I’d also like to check this initial version of the project into git so others can follow along more easily.
December Adventure Day 03
ROMs and Time Zones
Continuing my Psion-themed December Adventure, I returned to Sunday’s sorting of ROMs, and chipped away at the OPL take on Thoughts I started yesterday. I’m also rapidly learning that one of the advantages of slowing down a little and writing up my daily adventures is getting to share them with others and, quite wonderfully, receiving realtime feedback, suggestions and contributions.
ROMs
I spent some time adding more Psion ROMs to explit7/Psion-ROM—Sienna, Series 3c, and Series 3mx this time around. Thanks to feedback from Alex, I’ve also added MD5 checksums and EPOC16 versions (the latter really helps differentiate between device variants and language variants). There are still a few gaps in the table as I’ve yet to find for myself where the EPOC16 version comes from but hopefully I can flesh that out over the next couple of days.
The additions are as follows:
Psion Siena
Device | EPOC16 Version | ROM Version | Language | Filename | MD5 Checksum |
---|---|---|---|---|---|
Psion Siena | 3.70f | 4.20f | English | vine_v4.20f.bin | 242e80fdbf9b353a05f6ff4d1db1c769 |
Psion Series 3c
Device | EPOC16 Version | ROM Version | Language | Filename | MD5 Checksum |
---|---|---|---|---|---|
Psion Series 3c | 3.91f | 5.20f | English | oak_v5.20f_eng.bin | 3c1a079f53c00916e8d0dc11b35a0390 |
Psion Series 3mx
Device | EPOC16 Version | ROM Version | Language | Filename | MD5 Checksum |
---|---|---|---|---|---|
Psion Series 3mx | 4.08f | 6.16f | English | maple_v6.16f_uk.bin | 64572cc3522447179d1e6f3b8fb45360 |
Psion Series 3mx | 6.20f | French | maple_v6.20f_fre.bin | 1b367e2fb862545cf420ff74a7f85ea7 |
Looking over the listings of ROMs have been dumped for these devices, it’s abundantly clear that we’re missing many localizations—I understand we know of at least English, French, German, Italian, Flemish, and Dutch variants of the 3c, so we’ve a long way to go. If you have a non-English version of these devices, please get in touch by dropping me a mail or joining us in Discord. We’d absolutely love to talk you through the process and would be eternally grateful!
As an aside, I love how the ROM files use the device codenames and am tempted to surface those as an explicit column. I’ve also just learned that we have a newly minted Series 7 ROM dump, so that’s on the list for tomorrow.
Thoughts-Lite
Fabrice Cappaert spotted a bug in my ZPAD
implementation (see yesterday’s notes)—I was using an IF
instead of a WHILE
-loop when checking to see if my target string was long enough, meaning it would never have worked for pads longer than 1. (Just goes to show that even for small programs, it’s worth unit testing things.) Thankfully, he also offered up a significant simplification which we both suspect will work more efficiently given the costs of certain operations in OPL:
PROC ZPAD$:(value&,length%)
REM Left-pad strings with '0' up to a length, length%.
RETURN RIGHT$(REPT$("0",length%)+NUM$(value&,length%),length%)
ENDP
This approach also allows me to avoid using a local fixed-size string which makes it far more flexible than the previous implementation which implicitly limited length%
to 10.
Perhaps most-crucially however, was Fabrice’s help with OPL time zone wrangling: the macOS version of Thoughts stores the UTC offset in the ISO 8601 timestamp and this is functionality I really want to preserve in the OPL version1. Yesterday, I was starting to get a little nervous about my ability to implement this as I couldn’t find built-in functions to get the user’s UTC offset, even though it’s clear from the World app that the Psion knows where I am and my time relative to UTC.
Thankfully, Fabrice pointed me at one of the first-party after-market native libraries—SystInfo.opx—which provides SIUTCOffset&:
. This returns the UTC offset in seconds and, a few calls to MOD
and IABS
later, I was able add the UTC offset to my ISO 8601 formatted date:
PROC ISO8601$:(datetime&,utcOffset&)
REM Return an ISO 8601 formatted date and time with UTC offset.
REM Result is guaranteed to be 25 characters long.
LOCAL year&, month&, day&, hour&, minute&, second&
LOCAL offsetSign$(1), offsetHours&, offsetMinutes&
LOCAL result$(25)
REM Extract the datetime components.
year&=DTYEAR&:(datetime&)
month&=DTMONTH&:(datetime&)
day&=DTDAY&:(datetime&)
hour&=DTHOUR&:(datetime&)
minute&=DTMINUTE&:(datetime&)
second&=DTSECOND&:(datetime&)
REM Extract the offset components.
offsetHours&=IABS(utcOffset&/3600)
offsetMinutes&=MOD&:(utcOffset&/60,60)
IF (utcOffset& < 0)
offsetSign$="-"
ELSE
offsetSign$="+"
ENDIF
RETURN NUM$(year&,4) + "-" + ZPAD$:(month&,2) + "-" + ZPAD$:(day&,2) + "T" + ZPAD$:(hour&,2) + ":" + ZPAD$:(minute&,2) + ":" + ZPAD$:(second&,2) + offsetSign$ + ZPAD$:(offsetHours&,2) + ":" + ZPAD$:(offsetMinutes&,2)
ENDP
It’s not all been plain sailing though. In addition to storing an ISO 8601 date and time with a UTC offset in the note metadata, I wish to name the file using a UTC date and time to ensure they sort correctly on the file system. For example, a note published at 2024-12-02T16:05:06-10:00
should be named 2024-12-03-02-05-06.md
. While I had expected this to be simple (subtract utcOffset%
from datetime&
and generate a string similarly to the code above), it seems OPL date/times are a little bit magical.
Consider the following code:
LOCAL timestamp&, offset&, utcTimestamp&
LOCAL year&
REM Get the current timestamp and offset.
timestamp&=DTNOW&:
offset&=SIUTCOffset&:
REM Calculate the UTC timestamp.
utcTimestamp&=timestamp&-offset&
REM Extract the datetime components.
year&=DTYEAR&:(utcDatetime&)
While you might expect this to work, it fails with ‘Invalid arguments’ as utcTimestamp&
is apparently no longer a valid date/time, even though both timestamp&
and utcTimestamp&
both behave like longs when passed to many functions. I can only assume they’re backed by a magical object under the hood. Hopefully I can find a way to get the UTC timestamp directly, or initialize a new date/time with a long, but that’s one for another day.