Resampling Sharp PC-1500 tape recordings
Wednesday, 16th October 2024
This is a quick post about problems I'd been having loading tape cassette recordings from my PC to a Sharp PC-1500 Pocket Computer along with a potential solution for anyone having similar issues.
The Sharp PC-1500 is a small computer from the early 1980s that can run programs primarily written in BASIC. Programs can be saved to and loaded back from cassette tape but to do this requires it to be connected to the CE-150, a cassette interface that also includes a four-colour plotter. It's the plotter that really attracted me to the computer in the first place, but being able to load programs from cassette rather than having to type them in by hand is definitely a very handy feature!
Unfortunately this CE-150 interface contains an internal battery pack of five Ni-Cd cells. A certain amount of battery leakage is a risk in any old piece of electronics hardware, but having said battery pack soldered directly in with no easy way for the user to remove it for long-term storage makes it more of a certainty than a risk. My CE-150 had not escaped, with heavy corrosion visible on the metal plate under the computer…
The inside of the PC-1500 was a bit better and not quite as musty-smelling, but circuit board traces had definitely been eaten away in places and some of the internal metal structure was looking a bit crusty.
I cleaned up the rusty metal using some appliance descaler (and recorded a video of the process, which can be found on YouTube) then got to work with a scalpel and some fine enamelled copper wire to repair the damaged traces.
Thankfully when I put it all back together again I was greeted by the NEW0? :CHECK prompt, so I connected the computer to the CE-150 and confirmed I could save and load back programs using my PC in place of a cassette recorder. Hooray!
When looking to see what other people had managed to achieve with the computer I found the Sharp PC-1500 (TRS-80 PC-2) resource page which hosts several games and applications which can be downloaded in WAV format. I downloaded a few, but when playing them back on my PC the PC-1500 would refuse to load them. It could load from my own recordings, why not these?
The odd thing is that my own recordings started with a few seconds of constant pilot tone followed by the varying pulses of the program data I was trying to load. When playing back the downloaded recordings they'd start with silence and only make sound when they got to the actual data – and that data sounded quite unlike what my own recordings sounded like. What was going on? I opened a recording in Audacity to see what it looked like, and zoomed in:
That looks like the pilot tone, in that it alternates between high and low at a fixed frequency, though it's only one sample high then one sample low so is a square wave rather than the smooth sine wave I was expecting. The cassette format uses frequency-shift keying so I would expect to see data further on in the recording at half the frequency, or twice the period, i.e. two samples high and then two samples low. Scrolling along, I certainly find that:
Why does it look so spiky, though? It would only need to look like that if it had been sampled at an extremely low rate, and indeed that's what I found – these files are recorded at a 5kHz sample rate:
5kHz is a pretty unusual (and very low!) sample rate that I suspected my sound card was not handling particularly gracefully. Typically audio would need to be resampled to 44.1kHz or 48kHz before playing back, so I thought I'd try resampling in Audacity. This produced this weirdness:
Instead of a sine wave that alternates between two frequencies, I ended up with something more akin to an amplitude-modulated signal! The high-frequency pilot also disappears entirely, apart from what looks to be some initial ringing:
Now, this is a complete guess, but I suspect that what is happening is an attempt to reduce unpleasant harmonics in the resampled audio. A square wave (such as the one we're resampling) produces a lot of harmonics at higher frequencies. As the original audio was sampled at 5kHz, the highest frequency that this could represent was 2.5kHz (alternating high and low each sample, as per the pilot tone). A low-pass filter at 2.5kHz could therefore reduce the harmonics, though I'd still expect some of it to get through (rather than it being apparently being attenuated to zero in this case!) In any case, the signal is being destroyed by the resampling process, so it's no wonder the computer can't load the data.
So, what's the solution? A simpler resampling algorithm that just duplicates samples (a sort of "nearest neighbour") should give us an acceptable square wave when processing, however I could not find any way to achieve this natively in Audacity. Fortunately, though, there's a Nyquist Plugin script that can be used to repeat all samples (install via Tools→Nyquish Plugin Installer, then check it's listed in Tools→Plugin Manager – it will appear as Effect→Repeat Samples). As we want to end up around 44.1kHz the plugin can be used to repeat each sample eight times, which would take our 5kHz sample rate to 40kHz:
↓
When played back the pilot tone can finally be heard, but it's at much too low a pitch! This is because the samples have been repeated eight times, but the track's sample rate hasn't been increased to match. This can be done by right-clicking the track and selecting Rate→Other and entering 40kHz:
Now when the clip is played back it sounds as it should. As a further experiment, the sample rate could be changed from its somewhat odd 40kHz to a more conventional 44.1kHz via Tracks→Resample. When this is done, the nice clean square wave loses definition again:
As I mentioned earlier, square waves have a lot of harmonics, and the resampling process makes these harmonics extremely visible riding on top and bottom of each cycle of the square wave. There is one way to clean this up quite nicely, however – as we want to cull high-frequency harmonics, and as the original recording could only represent frequencies up to 2.5kHz we can apply a low-pass filter at 2.5kHz from the Effects menu:
↓
This resulting file sounds much better and loads into the computer as it should. It's a bit of a convoluted process, but it does at least bypass the resampling weirdness that distorts the original files in such a way that they can't be loaded. The only additional step I might recommend is to more neatly balance the signal around zero by normalising it as the first step:
In summary, the steps are as follows:
- Normalise the file (Effect→Normalize)
- Repeat all samples by 8× (Effect→Repeat Samples)
- Set track sample rate to 8× what it was (right-click, Rate→Other, multiply the value by 8)
- Resample to 44.1kHz (Tracks→Resample)
- Low-pass filter at 2.5kHz (Effect→Low-Pass Filter)
Happy tape loading!
1-Wire interfacing with the Cambridge Z88
Sunday, 17th December 2023
I've been having a tricky time buying LM35DZ analogue temperature sensors for a project recently. One pair of probes and a bag of loose components labelled LM35DZ turned out to be regular NPN transistors with a fake label on them, and another pair of probes ended up being DS18B20 digital temperature sensors.
Whilst the DS18B20 temperature sensors were useless for the project I had in mind they were still functioning components. These use the 1-Wire serial bus, a bus named for the way that its single data line can also be used to parasitically power the devices on the bus. Electrically the bus is open drain with a pull-up resistor that idles in the high state which any device can drive low. The master initiates all communication and you can have multiple peripheral devices connected to the bus in an arrangement called a MicroLAN.
I'd had some limited experience working with the 1-Wire bus as part of my version of the Superprobe but now that I had a collection of temperature sensors I thought it might be worth revisiting, this time on the Cambridge Z88.
1-Wire adaptor for the Z88 serial port
To connect 1-Wire devices to the Z88 some sort of adaptor is required and one that plugged into the computer's serial port seemed like a sensible enough option. The Z88's serial port hardware normally handles all the communications for you however it is possible to directly control the logic levels of the serial port's output pins and read back the status of the input pins via some hardware registers.
The TXD line can be +5V for a logic 0 and -6V for a logic 1, adhering to the RS-232 standard. When idle TXD is in its logic 1 state, outputting -6V. Bit ITX (3) in the TXC (&E4) register can be used to invert the behaviour of the TXD pin, so by setting this bit we can change the state of the pin from -6V to +5V.
As we need to have an open-drain bus we can use an NPN transistor with the base connected to the TXD line via a current-limiting resistor, the emitter connected to ground and the collector driving the 1-Wire bus. By default the TXD pin will output -6V, the transistor will be switched off and the bus will be pulled high. When the TXD pin state is inverted it will output +5V, the transistor will switch on and drive the line low.
The state of the RXD line can be read directly via bit RXD (4) in the RXE (&E1) register. The lines appear to be weakly held to 0V and read back a 0 bit in this state, flipping to a 1 bit when the voltage rises above around 2V. In this case we can connect the 1-Wire bus directly to the RXD input and be able to read back the current state.
The circuit for the adaptor, including the 4.7K pull-up resistor, appears as follows:
This can be tested in a BASIC program. To determine the input state we can read from the RXE port register (&E1) and check the state of the RXD bit (4):
10 RXE=&E1:M_RXERXD=&10 20 REPEAT 30 PRINT ~(GET(RXE) AND M_RXERXD) 40 UNTIL FALSE
The mask value M_RXERXD is specified as 24=&10 to correspond to the bit four. When run this program displays &10 in hex (showing bit 4 is set and the bus level is therefore high) until the 1-Wire bus line is connected to ground, when the value changes to 0 (showing bit 4 is reset and the bus level is therefore low).
To change the output state we need to write to bit ITX (3) of the TXC register (&E4). However, when writing to the hardware port we only want to change that bit and leave the others alone. The TXC register is a write-only port, so we can't retrieve its previous state by reading from the port. Fortunately the OS maintains a copy of the last value written as a "soft copy" in RAM at address &04E4 and this can be read with the ? indirection operator:
10 TXC=&E4:M_TXCITX=&08 20 SC=&400 30 TXC_OLD=SC?TXC 40 PUT TXC,TXC_OLD OR M_TXCITX 50 IF INKEY(100) 60 PUT TXC,TXC_OLD
The above program reads the old state of the TXC port from the soft copy, ORs it with the mask of the ITX bit (23=&08) and then outputs that to the TXC port. This has the effect of inverting the TXD line, driving the 1-Wire bus low. The program then waits one second with a dummy keyboard read before restoring the old value of the TXC port to release the 1-Wire bus.
Normally if changing the state of the serial port it would be good manners to update the soft copy of the serial port state however as the program is just going to be sending short low pulses before returning the port to its previous state this step is omitted.
After testing that the circuit worked on a breadboard a more permanent version was assembled in a DE-9 shell as above. As the clips that hold in the DE-9 connector had to cut off to allow it to fit in the Z88's recessed port the circuit ended up being secured with copious amounts of hot glue, which is far from ideal, but nobody will see when it's all screwed back together.
Bit-level protocol
Now that we can electrically control the bus we need to know how to transfer data on it. This is done by timed pulses, where the bus master will hold the bus line low for a certain amount of time, release it, then check to see if any devices on the bus are holding it low in return. This is summarised in the following timing diagram from Microchip's AN1199, 1-Wire Communication with PIC Microcontroller:
The first thing that needs to be done is to reset all devices on the bus. This is done by holding the bus low for 480μs then releasing it for at least 480μs. If any peripheral devices are present on the bus they will drive the line low after the low pulse from the master, so the full reset procedure is as follows:
- Master drives bus low
- Delay 480μs
- Master releases bus high
- Delay 70μs
- Sample bus state: if high, no peripheral devices present, if low at least one device present.
- Delay 410μs
Once reset, data can be transmitted from the master to peripheral devices bit-by-bit in a similar fashion to the reset pulse, albeit with different timing.
To send a 0 bit:
- Master drives bus low
- Delay 60μs
- Master releases bus high
- Delay 10μs
To send a 1 bit:
- Master drives bus low
- Delay 6μs
- Master releases bus high
- Delay 64μs
Bytes are transferred as eight individual bits, least-significant bit first. The protocol is also tolerant of large delays between individual bits.
Once data has been sent to a peripheral, it may respond with data of its own. The master is still in control of clocking the data out of the peripheral, and the process is as follows:
- Master drives bus low
- Delay 6μs
- Master releases bus high
- Delay 9μs
- Sample bus state to read data bit from peripheral
- Delay 55μs
The overall timing for reading a bit is the same as the timing for sending a 1 bit (an initial 6μs low pulse from the master and a total bit time of 70μs) so in practice only one routine needs to be implemented and the value returned from the bus during read operations can be ignored during write operations.
Software choice for the Z88
I thought it would be nice to be able to interact with 1-Wire devices from a BASIC program. BBC BASIC on the Z88 does provide direct access to the hardware and would make controlling the 1-Wire bus line possible, as demonstrated earlier, however I don't think it would provide the timing accuracy required to produce the appropriate pulses from the master. Fortunately it does include a Z80 assembler and so a mixture of a BASIC program that provides the high-level routines and assembly snippets for the low-level 1-Wire protocol implementation seemed like an appropriate mix of languages.
When you CALL an assembly routine from BASIC the Z80's registers are initialised to the values of the corresponding static variables, for example A is set to A%, H to H%, L to L% etc. You can't return a value directly – for that you'd need USR – however it's a bit easier to just store the return value in memory and retrieve that from BASIC after the CALL returns.
A rough starting point for the 1-Wire program is as follows:
10 REM 1-WIRE DEMO 20 PROC_1W_INIT 30 PRINT FN_1W_RESET 40 END 50 : 60 REM 1-WIRE ROUTINES 70 END 80 DEFPROC_1W_INIT 90 ow_code_size=256:DIM ow_code ow_code_size-1 100 RXE=&E1:M_RXERXD=&10 110 TXC=&E4:M_TXCITX=&08 120 SC=&400 130 FOR opt=0 TO 2 STEP 2 140 P%=ow_code 150 [OPT opt 160 .ow_buf DEFB 0 \ temporary transfer buffer 170 : 180 .ow_reset 190 IN A,(RXE):AND M_RXERXD:CP M_RXERXD:SBC A,A:LD (ow_buf),A:RET NZ \ check bus is idle 200 DI:LD A,(SC+TXC):OR M_TXCITX:OUT (TXC),A \ hold bus low 210 LD B,120:DJNZ P% \ delay 220 AND NOT M_TXCITX:OUT (TXC),A \ release bus 230 LD B,18:DJNZ P% \ delay 240 IN A,(RXE):AND M_RXERXD:CP M_RXERXD:CCF:SBC A,A:LD (ow_buf),A \ sample presence 250 LD B,100:DJNZ P% \ delay 260 EI:RET 270 : 280 ] 290 NEXT 300 ENDPROC 310 : 320 REM Resets bus, retuns TRUE if any devices are present 330 DEFFN_1W_RESET:CALL ow_reset:=?ow_buf=0
The first few lines are going to be where our BASIC program is. This calls the procedure PROC_1W_INIT which will set things up by assembling any required Z80 code. It then calls FN_1W_RESET which is a function that resets the 1-Wire bus and checks to see if any devices assert their presence.
PROC_1W_INIT starts by allocating some memory for the assembled code to live, defines some constants for the IO ports and then runs through the two passes of the assembly process in a loop. Within the assembly block is a variable (ow_buf) which will be used to store data due to be returned by the assembly routines. The ow_reset assembly routine then follows – this first checks to see if the bus is idle (floating high) and if so it disables interrupts, holds the bus low for 480μs, releases the bus and waits 70μs, samples the state of the bus to check for device presence (storing the result in ow_buf), then delays another 410μs.
The delay loops are simple DJNZ loops with B corresponding to the length of the delay and the timings were roughly calculated first based on the number of cycles each loop would take and the Z88's 3.2768MHz CPU clock speed. They were then adjusted slightly using a logic analyser to ensure the timing was as close as could be managed to the 1-Wire protocol's specifications.
The ow_reset routine has been written so that following a successful presence check ow_buf should contain 0, and if there is a problem it will contain a non-zero value. This is used by the FN_1W_RESET wrapper function which just calls ow_reset and returns TRUE if ow_buf is zero afterwards.
If you run the program you should see that the program will display 0 (FALSE) on the screen until a 1-Wire device is connected to the adaptor, at which point it will display -1 (TRUE) instead to indicate the device's presence. This isn't a very useful program, but shows how BASIC and assembly will be mixed to build the rest of the 1-Wire routines.
Sending and receiving bits and bytes
Now that we know a device is present on the bus after a reset we need to be able to send and receive bits and bytes. Sending a 0 bit is a bit simpler than resetting, as we don't need to check for any response – just hold the line low for 60μs then release it back high for 10μs. This can be implemented as follows:
.ow_put_0 DI LD A,(SC+TXC):OR M_TXCITX:OUT (TXC),A \ hold bus low LD B,15:DJNZ P% \ delay AND NOT M_TXCITX:OUT (TXC),A \ release bus NOP \ delay EI:RET
Sending a 1 bit has the same overall timing as reading a bit, so instead of writing separate routines to send a 1 bit and read a bit just one routine is required that handles both situations:
.ow_put_1 DI LD A,(SC+TXC):OR M_TXCITX:OUT (TXC),A \ hold bus low NOP \ delay AND NOT M_TXCITX:OUT (TXC),A \ release bus PUSH HL:POP HL \ delay IN A,(RXE):AND M_RXERXD:SUB M_RXERXD:CCF \ sample bit LD A,(ow_buf):RRA:LD (ow_buf),A \ store bit LD B,7:DJNZ P% \ delay EI:RET
This holds the bus low for 6μs, releases it and waits 9μs, samples a bit from the bus and rotates it into the ow_buf transfer buffer, then waits 55μs.
These routines could be wrapped up for use in BASIC but it's not too useful to be able to send or receive single bits, normally we'd need to transfer whole 8-bit bytes. The ow_put_1 routine already handles updating the ow_buf with each received bit, so a byte receiving routine can be put together by just calling ow_put_1 eight times in a loop:
.ow_get_byte LD B,8 \ 8 bits to receive .ow_get_loop PUSH BC:CALL ow_put_1:POP BC \ receive single bit DJNZ ow_get_loop \ loop LD A,(ow_buf):RET \ store
A send routine can be put together with a similar loop that shifts out the bit to send and then calls either the ow_put_0 or ow_put_1 routine depending on whether it's a 0 or 1 bit that's required. Bits will usually be shifted out into the carry register, so a new ow_put_carry routine that sends the bit stored in the carry flag makes this a bit easier, e.g.
.ow_put_carry JR C,ow_put_1 JR ow_put_0
...which will be called by the ow_put_byte routine, as follows:
.ow_put_byte LD C,A:LD B,8 \ value to send in C, send 8 bits .ow_put_loop SRL C:PUSH BC:CALL ow_put_carry:POP BC \ shift and send single bit DJNZ ow_put_loop \ loop RET
It is also quite useful to be able to send or receive blocks of data at once – for example, sending or receiving the 64-bit device IDs requires sending or receiving 8 bytes of data at a time. To complement ow_get_byte and ow_put_byte we can write ow_get_bytes and ow_put_bytes routines to send or receive the block of data addressed by HL, length BC:
.ow_get_bytes LD A,B:OR C:RET Z:DEC BC \ have we finished? PUSH BC:CALL ow_get_byte:POP BC \ get a byte LD (HL),A:INC HL:JR ow_get_bytes \ store and loop : .ow_put_bytes LD A,B:OR C:RET Z:DEC BC \ have we finished? LD A,(HL):INC HL \ fetch PUSH BC:CALL ow_put_byte:POP BC:JR ow_put_bytes \ send and loop
All of these can now be wrapped up as procedures or functions so they can be more easily used from a BASIC program:
REM Transmits a single byte DEFPROC_1W_PUT(A%)CALL ow_put_byte:ENDPROC REM Transmits a block of bytes DEFPROC_1W_PUTS(L%,C%)LOCAL H%,B%:H%=L%DIV256:B%=C%DIV256:CALL ow_put_bytes:ENDPROC REM Receives a single byte DEFFN_1W_GET:CALL ow_get_byte:=?ow_buf REM Receives a block of bytes DEFPROC_1W_GETS(L%,C%)LOCAL H%,B%:H%=L%DIV256:B%=C%DIV256:CALL ow_get_bytes:ENDPROC
BASIC's integer variables are 32-bit integers so when passing the 16-bit address or length parameters the target register is the least-significant one (L for HL, C for BC) and the most-significant register (H or B) is populated by dividing the value by 256.
This can all be put together in the following demonstration program. It initialises the routines, resets the bus and checks for presence, then sends the "read ROM" command &33 which will make any connected devices respond with their ROM ID. It then reads back the eight bytes corresponding to the device ID then prints them back in hexadecimal.
10 DIM ID 7:REM Storage for device ID 20 PROC_1W_INIT 30 IF FN_1W_RESET=FALSE PRINT "No devices found.":END 40 PROC_1W_PUT(&33):REM "Read ROM" command 50 PROC_1W_GETS(ID,8):REM Read eight bytes of device ID 60 FOR I=7 TO 0 STEP -1:PRINT ~ID?I;:NEXT:PRINT:REM Print device ID bytes 70 END 80 : 90 REM 1-WIRE ROUTINES 100 END 110 DEFPROC_1W_INIT 120 ow_code_size=256:DIM ow_code ow_code_size-1 130 RXE=&E1:M_RXERXD=&10 140 TXC=&E4:M_TXCITX=&08 150 SC=&400 160 FOR opt=0 TO 2 STEP 2 170 P%=ow_code 180 [OPT opt 190 .ow_buf DEFB 0 \ temporary transfer buffer 200 : 210 .ow_reset 220 IN A,(RXE):AND M_RXERXD:CP M_RXERXD:SBC A,A:LD (ow_buf),A:RET NZ \ check bus is idle 230 DI:LD A,(SC+TXC):OR M_TXCITX:OUT (TXC),A \ hold bus low 240 LD B,120:DJNZ P% \ delay 250 AND NOT M_TXCITX:OUT (TXC),A \ release bus 260 LD B,18:DJNZ P% \ delay 270 IN A,(RXE):AND M_RXERXD:CP M_RXERXD:CCF:SBC A,A:LD (ow_buf),A \ sample presence 280 LD B,100:DJNZ P% \ delay 290 EI:RET 300 : 310 .ow_put_carry 320 JR C,ow_put_1 \ fall-through 330 : 340 .ow_put_0 350 DI 360 LD A,(SC+TXC):OR M_TXCITX:OUT (TXC),A \ hold bus low 370 LD B,15:DJNZ P% \ delay 380 AND NOT M_TXCITX:OUT (TXC),A \ release bus 390 NOP \ delay 400 EI:RET 410 : 420 .ow_put_1 430 DI 440 LD A,(SC+TXC):OR M_TXCITX:OUT (TXC),A \ hold bus low 450 NOP \ delay 460 AND NOT M_TXCITX:OUT (TXC),A \ release bus 470 PUSH HL:POP HL \ delay 480 IN A,(RXE):AND M_RXERXD:SUB M_RXERXD:CCF \ sample bit 490 LD A,(ow_buf):RRA:LD (ow_buf),A \ store bit 500 LD B,7:DJNZ P% \ delay 510 EI:RET 520 : 530 .ow_put_byte 540 LD C,A:LD B,8 \ value to send in C, send 8 bits 550 .ow_put_loop 560 SRL C:PUSH BC:CALL ow_put_carry:POP BC \ shift and send single bit 570 DJNZ ow_put_loop \ loop 580 RET 590 : 600 .ow_put_bytes 610 LD A,B:OR C:RET Z:DEC BC \ have we finished? 620 LD A,(HL):INC HL \ fetch 630 PUSH BC:CALL ow_put_byte:POP BC:JR ow_put_bytes \ send and loop 640 : 650 .ow_get_byte 660 LD B,8 \ 8 bits to receive 670 .ow_get_loop 680 PUSH BC:CALL ow_put_1:POP BC \ receive single bit 690 DJNZ ow_get_loop \ loop 700 LD A,(ow_buf):RET \ store 710 : 720 .ow_get_bytes 730 LD A,B:OR C:RET Z:DEC BC \ have we finished? 740 PUSH BC:CALL ow_get_byte:POP BC \ get a byte 750 LD (HL),A:INC HL:JR ow_get_bytes \ store and loop 760 : 770 ] 780 NEXT 790 IF P%-ow_code>ow_code_size PRINT"Code size: "P%-ow_code:END 800 ENDPROC 810 : 820 REM Resets bus, retuns TRUE if any devices are present 830 DEFFN_1W_RESET:CALL ow_reset:=?ow_buf=0 840 REM Transmits a single byte 850 DEFPROC_1W_PUT(A%)CALL ow_put_byte:ENDPROC 860 REM Transmits a block of bytes 870 DEFPROC_1W_PUTS(L%,C%)LOCAL H%,B%:H%=L%DIV256:B%=C%DIV256:CALL ow_put_bytes:ENDPROC 880 REM Receives a single byte 890 DEFFN_1W_GET:CALL ow_get_byte:=?ow_buf 900 REM Receives a block of bytes 910 DEFPROC_1W_GETS(L%,C%)LOCAL H%,B%:H%=L%DIV256:B%=C%DIV256:CALL ow_get_bytes:ENDPROC
When connected to an iButton fob the program prints
55 0 0 1 A0 1A 57 1
...which matches the ID printed on it.
When connected to a DS18B20 temperature sensor the program prints
B9 0 0 1 D1 97 5D 28
The least significant byte of the 64-bit ID is the family code – &01 for the iButton fob indicates it's a "silicon serial number" type device and &28 for the DS18B20 indicates it's a "programmable resolution digital thermometer".
The 1-Wire bus supports multiple peripheral devices connected to a single master. If we try that we still get something that looks like an ID back:
11 0 0 1 80 12 55 0
This happens because it's an open-drain bus and any device holding the line low will take priority over any device releasing the line high. In effect the data read back is ANDed together, so the most-significant byte received is &55 AND &B9 which gives us the &11 we see. Fortunately that most-significant byte does give us a good opportunity to detect such invalid data!
Error detection with a CRC
Some data payloads include a CRC value. The most-significant byte of a 64-bit device ID is such a CRC, with the least-significant byte being the family code. The exact details for the CRC calculation can be found in the article Understanding and Using Cyclic Redundancy Checks with Maxim 1-Wire and iButton Products however for our purposes a Z80 implementation can be written as follows:
.ow_crc LD B,8:LD DE,(ow_buf):LD D,A \ E = accumulated CRC, D = value to add .ow_crc_loop LD A,E:XOR D:SRL D:SRL A:JR C,ow_crc_odd \ XOR and shift bits SRL E:DJNZ ow_crc_loop:LD A,E:LD (ow_buf),A:RET \ even CRC value .ow_crc_odd:SRL E:LD A,&8C:XOR E:LD E,A:DJNZ ow_crc_loop:LD (ow_buf),A:RET \ odd CRC value : .ow_crc_block XOR A:LD (ow_buf),A \ reset CRC .ow_crc_block_loop LD A,B:OR C:LD A,(ow_buf):RET Z:DEC BC \ have we finished? LD A,(HL):INC HL:PUSH BC:CALL ow_crc:POP BC:JR ow_crc_block_loop \ update CRC
ow_crc updates the current calculated CRC value (stored in ow_buf) with the next data byte from the accumulator. ow_crc_block calculates the CRC for a block of data pointed to by HL, length BC, using the ow_crc routine. A couple of BASIC functions can then be written, one to calculate the CRC of a block of data and another to check that the last byte of the block corresponds to the CRC of the preceding data:
REM Calculates the CRC of a block of data DEFFN_1W_CRC(L%,C%)LOCAL H%,B%:H%=L% DIV256:B%=C%DIV256:CALL ow_crc_block:=?ow_buf REM Checks if a CRC at the end of a block of data matches DEFFN_1W_CRC_CHECK(L%,C%)=FN_1W_CRC(L%,C%)=(L%?C%)
These two can now be used to check that a device ID is valid. The CRC is also appended to other data reports, such as reading the scratchpad memory of a temperature sensor, so it's a useful routine to have. A new program which checks the CRC is as follows:
10 DIM ID 7 20 PROC_1W_INIT 30 REPEAT 40 REPEAT UNTIL FN_1W_RESET:REM Wait for device to be present 50 PROC_1W_PUT(&33):REM Read ROM 60 PROC_1W_GETS(ID,8):REM Fetch ID 70 IF FN_1W_CRC_CHECK(ID,7) VDU 7:PRINT "Detected ";FN_1W_ID$(ID);" at ";TIME$:REM Print if valid 80 REPEAT UNTIL FN_1W_RESET=FALSE:REM Wait for device to be disconnected 90 UNTIL FALSE 100 END 110 : 120 REM 1-WIRE ROUTINES 130 END 140 DEFPROC_1W_INIT 150 ow_code_size=256:DIM ow_code ow_code_size-1 160 RXE=&E1:M_RXERXD=&10 170 TXC=&E4:M_TXCITX=&08 180 SC=&400 190 FOR opt=0 TO 2 STEP 2 200 P%=ow_code 210 [OPT opt 220 .ow_buf DEFB 0 \ temporary transfer buffer 230 .ow_conf DEFB 0 \ stores last bit conflict index 240 : 250 .ow_reset 260 IN A,(RXE):AND M_RXERXD:CP M_RXERXD:SBC A,A:LD (ow_buf),A:RET NZ \ check bus is idle 270 DI:LD A,(SC+TXC):OR M_TXCITX:OUT (TXC),A \ hold bus low 280 LD B,120:DJNZ P% \ delay 290 AND NOT M_TXCITX:OUT (TXC),A \ release bus 300 LD B,18:DJNZ P% \ delay 310 IN A,(RXE):AND M_RXERXD:CP M_RXERXD:CCF:SBC A,A:LD (ow_buf),A \ sample presence 320 LD B,100:DJNZ P% \ delay 330 EI:RET 340 : 350 .ow_put_carry 360 JR C,ow_put_1 \ fall-through 370 : 380 .ow_put_0 390 DI 400 LD A,(SC+TXC):OR M_TXCITX:OUT (TXC),A \ hold bus low 410 LD B,15:DJNZ P% \ delay 420 AND NOT M_TXCITX:OUT (TXC),A \ release bus 430 NOP \ delay 440 EI:RET 450 : 460 .ow_put_1 470 DI 480 LD A,(SC+TXC):OR M_TXCITX:OUT (TXC),A \ hold bus low 490 NOP \ delay 500 AND NOT M_TXCITX:OUT (TXC),A \ release bus 510 PUSH HL:POP HL \ delay 520 IN A,(RXE):AND M_RXERXD:SUB M_RXERXD:CCF \ sample bit 530 LD A,(ow_buf):RRA:LD (ow_buf),A \ store bit 540 LD B,7:DJNZ P% \ delay 550 EI:RET 560 : 570 .ow_put_byte 580 LD C,A:LD B,8 \ value to send in C, send 8 bits 590 .ow_put_loop 600 SRL C:PUSH BC:CALL ow_put_carry:POP BC \ shift and send single bit 610 DJNZ ow_put_loop \ loop 620 RET 630 : 640 .ow_put_bytes 650 LD A,B:OR C:RET Z:DEC BC \ have we finished? 660 LD A,(HL):INC HL \ fetch 670 PUSH BC:CALL ow_put_byte:POP BC:JR ow_put_bytes \ send and loop 680 : 690 .ow_get_byte 700 LD B,8 \ 8 bits to receive 710 .ow_get_loop 720 PUSH BC:CALL ow_put_1:POP BC \ receive single bit 730 DJNZ ow_get_loop \ loop 740 LD A,(ow_buf):RET \ store 750 : 760 .ow_get_bytes 770 LD A,B:OR C:RET Z:DEC BC \ have we finished? 780 PUSH BC:CALL ow_get_byte:POP BC \ get a byte 790 LD (HL),A:INC HL:JR ow_get_bytes \ store and loop 800 : 810 .ow_crc 820 LD B,8:LD DE,(ow_buf):LD D,A \ E = accumulated CRC, D = value to add 830 .ow_crc_loop 840 LD A,E:XOR D:SRL D:SRL A:JR C,ow_crc_odd \ XOR and shift bits 850 SRL E:DJNZ ow_crc_loop:LD A,E:LD (ow_buf),A:RET \ even CRC value 860 .ow_crc_odd:SRL E:LD A,&8C:XOR E:LD E,A:DJNZ ow_crc_loop:LD (ow_buf),A:RET \ odd CRC value 870 : 880 .ow_crc_block 890 XOR A:LD (ow_buf),A \ reset CRC 900 .ow_crc_block_loop 910 LD A,B:OR C:LD A,(ow_buf):RET Z:DEC BC \ have we finished? 920 LD A,(HL):INC HL:PUSH BC:CALL ow_crc:POP BC:JR ow_crc_block_loop \ update CRC 930 ] 940 NEXT 950 IF P%-ow_code>ow_code_size PRINT"Code size: "P%-ow_code:END 960 ENDPROC 970 : 980 REM Resets bus, retuns TRUE if any devices are present 990 DEFFN_1W_RESET:CALL ow_reset:=?ow_buf=0 1000 REM Transmits a single byte 1010 DEFPROC_1W_PUT(A%)CALL ow_put_byte:ENDPROC 1020 REM Transmits a block of bytes 1030 DEFPROC_1W_PUTS(L%,C%)LOCAL H%,B%:H%=L%DIV256:B%=C%DIV256:CALL ow_put_bytes:ENDPROC 1040 REM Receives a single byte 1050 DEFFN_1W_GET:CALL ow_get_byte:=?ow_buf 1060 REM Receives a block of bytes 1070 DEFPROC_1W_GETS(L%,C%)LOCAL H%,B%:H%=L%DIV256:B%=C%DIV256:CALL ow_get_bytes:ENDPROC 1080 : 1090 REM Converts ID bytes into string 1100 DEFFN_1W_ID$(ID)LOCAL I%:S$="":FOR I%=7 TO 0 STEP -1:IF ID?I%>15:S$=S$+STR$~(ID?I%):NEXT:=S$:ELSE:S$=S$+"0"+STR$~(ID?I%):NEXT:=S$ 1110 REM Converts string into ID bytes 1120 DEFPROC_1W_ID$(ID,ID$)LOCAL I%:FOR I%=0 TO 7:ID?I%=EVAL("&"+MID$(ID$,15-I%*2,2)):NEXT:ENDPROC 1130 : 1140 REM Calculates the CRC of a block of data 1150 DEFFN_1W_CRC(L%,C%)LOCAL H%,B%:H%=L% DIV256:B%=C%DIV256:CALL ow_crc_block:=?ow_buf 1160 REM Checks if a CRC at the end of a block of data matches 1170 DEFFN_1W_CRC_CHECK(L%,C%)=FN_1W_CRC(L%,C%)=(L%?C%)
The program waits for a device to be present, reads its ID, then prints it to the screen along with the date and time if its CRC is valid. It then waits for the device to be removed before looping around to check again. This allows you to tap iButtons to a reader and it will display the relevant ID, for example. It also adds a couple of utility routines – a function, FN_1W_ID$(ID), which turns a block of ID data bytes into a string and a procedure, PROC_1W_ID$(ID,ID$), which does the opposite.
Enumerating the 1-Wire bus
It's certainly useful to be able to detect a single device on the 1-Wire bus however it would be more useful to detect multiple devices and be able to address them individually. Checking every single possible 64-bit address for a response would take far too long, but fortunately there is a way to very quickly enumerate every peripheral device on the bus by means of a binary search.
To start the search, the master sends either the normal search command &F0 or the alarm/conditional search command &EC. When using the conditional search only devices that are in some sort of alarm state will respond, allowing the master to more quickly identify the devices that need attention. As we're interested in all devices we'll use the normal search command &F0.
After issuing the search command all active devices on the bus will start to report their ID, bit by bit. Each device will send each bit twice, firstly in its normal state and then again in an inverted state. Due to the open-drain nature of the bus, this allows the master to detect conflicting bit values – if all active devices have a 0 in the current bit position then the bus will read 0 then 1, if all active devices have a 1 in the current bit position then the bus will read 1 then 0 but if there is a mixture of zeroes and ones then the bus will read 0 then 0.
After this the master sends a single bit that tells the active peripheral devices which bit it has identified. If this does not match the peripheral's current bit value then the peripheral will go into an idle state and stop responding until the bus is reset again, but if it does match then the device will continue to send bits of its ID. This allows the master to walk down both branches of the binary tree when searching for device IDs when it detects a conflict, by first selecting one bit value in one iteration of the search and then the other bit value in another iteration of the search.
The full procedure for enumerating the bus is more explicitly described in the app note 1-Wire Search Algorithm, and can be implemented with the following Z80 assembly code:
.ow_conf DEFB 0 \ stores last bit conflict index : .ow_search LD DE,(ow_conf):LD D,0:LD C,1:LD B,64 .ow_search_loop PUSH BC:CALL ow_put_1:CALL ow_put_1:POP BC:RLCA:RLCA \ get bit, !bit AND 3:JR Z,ow_search_conf \ 00 = conflict DEC A:JR Z,ow_search_1 \ 01 = 0 bit DEC A:JR Z,ow_search_0 \ 10 = 1 bit SCF:RET \ report failure .ow_search_conf LD A,B:CP E \ how does bit index compare to last conflict JR C,ow_search_0_conf \ 0, update current discrepancy JR Z,ow_search_1 \ 1, no update LD A,(HL):AND C:JR NZ,ow_search_advance \ old bit = 1, just advance LD D,B:JR ow_search_advance \ old bit = 0, update current discrepancy .ow_search_1:LD A,C:OR (HL):LD (HL),A:JR ow_search_advance .ow_search_0_conf:LD D,B \ fall-through .ow_search_0:LD A,C:CPL:AND (HL):LD (HL),A \ fall-through .ow_search_advance LD A,(HL):AND C:SUB C:CCF:PUSH BC:CALL ow_put_carry:POP BC \ return the ID bit RLC C:JR NC,P%+3:INC HL \ advance mask DJNZ ow_search_loop LD A,D:LD (ow_conf),A XOR A:LD (ow_buf),A:RET \ report success
A pair of BASIC wrappers can make using this search routine a bit easier:
REM Starts enumerating devices on the bus DEFPROC_1W_SEARCH_RESET:?ow_conf=TRUE:ENDPROC REM Searches for next device on bus. Pass search type &F0 for all devices, &EC for alarming devices. Returns TRUE if next device found DEFFN_1W_SEARCH(A%,ID)IF ?ow_conf=0:=FALSE ELSE IF FN_1W_RESET=0:=FALSE ELSE PROC_1W_PUT(A%):H%=ID DIV256:L%=ID:CALL ow_search:=?ow_buf=0
The "reset" routine just sets the last bit conflict index to -1 (TRUE=-1) and FN_1W_SEARCH will search based on the search type (&F0 for all devices, &EC for alarming devices only), the current ID and last conflict index and will return TRUE if an ID was found or FALSE if no more IDs were found.
A snippet of code that enumerates all devices on the bus and displays their IDs is as follows:
PROC_1W_SEARCH_RESET REPEAT F%=FN_1W_SEARCH(&F0,ID):IF F% PRINT FN_1W_ID$(ID) UNTIL F%=FALSE
Reading temperature sensors
So far the examples have been fairly uninteresting, but we now have enough support code to do something useful with devices on a 1-Wire network. The DS18B20 temperature sensors that inspired this whole project are probably the easiest way to show how useful the 1-Wire bus can be.
The idea here will be to search for all temperature sensors on the network and to display their current temperature reading alongside their ID on the screen. The temperature conversion is initiated by sending the "Convert T" command (&44) to the desired 1-Wire devices and then waiting for at least 750ms with the bus inactive, allowing the parasitically-powered devices enough power to complete the temperature conversion, after which the temperature can be read back from the sensor's scratchpad memory.
Due to the large delay when waiting for the sensors to handle the "Convert T" command it is easiest to send the command to all devices on the network rather than to each one individually. This can be done by first sending the "Skip ROM" command (&CC) which allows the master to skip sending a 64-bit ID to the specific device it's addressing before sending the "Convert T" command (&44). The process to tell all devices to perform a temperature conversion is as follows:
IF FN_1W_RESET=FALSE PRINT "No devices found":END REM Start temperature conversion PROC_1W_PUT(&CC):REM Skip ROM PROC_1W_PUT(&44):REM Convert T T=TIME:IF INKEY(75)>TRUE REPEAT:UNTIL TIME>T+75:REM Delay 750ms
INKEY(75) is used to delay for 750ms however as this can be skipped by pressing a key a delay loop is provided as a safety measure.
After this, all of the devices on the network are enumerated as before:
REM Search for all temperature sensors on the bus and display their readings PROC_1W_SEARCH_RESET REPEAT F%=FN_1W_SEARCH(&F0,ID) IF F% PROC_1W_PRINT_TEMP(ID) UNTIL F%=FALSE
PROC_1W_PRINT_TEMP should check to see whether the device ID corresponds to a temperature sensor (its family code, the least-significant byte, should be &28) and if so it should retrieve the temperature value and print it:
REM Print a single sensor's reading DEFPROC_1W_PRINT_TEMP(ID) LOCAL T IF ID?0<>&28 ENDPROC:REM Must be a temperature sensor T=FN_1W_READ_TEMP(ID):IF T=-999 ENDPROC:REM Read sensor and check for error @%=&20409:PRINT MID$(FN_1W_ID$(ID),3,12);":",T;" deg C":@%=&90A ENDPROC
@% controls the way numbers are printed – in this case it is changed to show four decimal places in a field width of 9 characters. When printing the device ID the first two characters and last two characters are stripped off as these correspond to the CRC and family code which are not particularly useful in this case.
FN_1W_READ_TEMP(ID) needs to fetch the temperature from the sensor with the specified ID or return -999 on error. A specific sensor can be addressed by first sending the match ROM command (&55) followed by the 64-bit device ID. After this the scratchpad RAM can be read by sending the "read scratchpad" command (&BE) then reading as many bytes as are required. We only need the first two, but will read nine as this includes all eight bytes of scratchpad RAM plus a CRC so we can verify the data is valid:
REM Retrieve a single sensor's reading DEFFN_1W_READ_TEMP(ID) LOCAL T IF FN_1W_RESET=FALSE =-999 PROC_1W_PUT(&55):PROC_1W_PUTS(ID,8):REM Match ROM PROC_1W_PUT(&BE):PROC_1W_GETS(SCRATCH,9):REM Read scratchpad IF FN_1W_CRC_CHECK(SCRATCH,8)=FALSE =-999:REM Check CRC =SCRATCH!-2DIV65536/16:REM Convert to degrees C
The final line converts the reading to °C. This is a signed 16-bit value stored in the first two bytes of the scratchpad memory. BBC BASIC's ! indirection operator reads a 32-bit value, so by reading from two bytes earlier (-2) the 16-bit temperature value is loaded into the most significant word of a 32-bit integer, and an integer divide of this by 65536 shifts this back down into the least significant word (where it should be) with the sign properly extended (so if it was a negative value before it will still be negative after the division). The value is then divided by 16 using a regular floating-point division as each unit of the temperature sensor's reported value corresponds to 1/16°C.
A complete demo program listing is shown below. Choosing option "3) Show DS18B20 temperatures" will show the temperatures of any connected DS18B20 temperature sensors.
10 REM 1-WIRE DEMONSTRATION FOR Z88 : BEN RYVES 2023 20 *NAME 1-Wire Demo 30 DIM ID 7,SCRATCH 8 40 PROC_1W_INIT 50 : 60 REM Main demo loop 70 REPEAT PROC_1W_DEMO_MENU 80 ON ERROR PRINT:END 90 PRINT "<Press any key>"; 100 REPEAT UNTIL INKEY(0)=TRUE:IF GET 110 UNTIL FALSE 120 END 130 : 140 REM Main menu 150 DEFPROC_1W_DEMO_MENU 160 CLS:PRINT CHR$1;"1B";"1-Wire Demonstration for Cambridge Z88";CHR$1;"1B" 170 ON ERROR END 180 REPEAT 190 PRINT '"Please choose a demo: (press ESC to exit)" 200 PRINT "1) Enumerate devices" 210 PRINT "2) Scan iButton tags" 220 PRINT "3) Show DS18B20 temperatures" 230 M%=GET-ASC"0" 240 UNTIL M%>0 AND M%<4 250 ON ERROR OFF 260 PRINT 270 ON M% PROC_1W_DEMO_LIST_DEVICES, PROC_1W_DEMO_TAG, PROC_1W_DEMO_SHOW_TEMPERATURES 280 ENDPROC 290 END 300 : 310 REM Device search demo 320 DEFPROC_1W_DEMO_LIST_DEVICES 330 PROC_1W_SEARCH_RESET 340 REPEAT F%=FN_1W_SEARCH(&F0,ID):IF F% PRINT FN_1W_ID$(ID) 350 UNTIL F%=FALSE:ENDPROC 360 : 370 REM ID tag scanning demo 380 DEFPROC_1W_DEMO_TAG 390 ON ERROR GOTO 50 400 PRINT "Tap a tag on the reader (press ESC to exit)" 410 REPEAT 420 REPEAT UNTIL FN_1W_RESET:REM Wait for device to be present 430 PROC_1W_PUT(&33):REM Read ROM 440 PROC_1W_GETS(ID,8):REM Fetch ID 450 IF FN_1W_CRC_CHECK(ID,7) AND ID?0=1 VDU 7:PRINT "Detected ";FN_1W_ID$(ID);" at ";TIME$:REM Print if valid 460 REPEAT UNTIL FN_1W_RESET=FALSE:REM Wait for device to be disconnected 470 UNTIL FALSE 480 ENDPROC 490 : 500 REM Temperature demo 510 DEFPROC_1W_DEMO_SHOW_TEMPERATURES 520 IF FN_1W_RESET=FALSE PRINT "No devices found":ENDPROC 530 REM Start temperature conversion 540 PROC_1W_PUT(&CC):REM Skip ROM 550 PROC_1W_PUT(&44):REM Convert T 560 T=TIME:IF INKEY(75)>TRUE REPEAT:UNTIL TIME>T+75:REM Delay 750ms 570 REM Search for all temperature sensors on the bus and display their readings 580 PROC_1W_SEARCH_RESET 590 REPEAT F%=FN_1W_SEARCH(&F0,ID) 600 IF F% PROC_1W_PRINT_TEMP(ID) 610 UNTIL F%=FALSE 620 ENDPROC 630 REM Print a single sensor's reading 640 DEFPROC_1W_PRINT_TEMP(ID) 650 LOCAL T 660 IF ID?0<>&28 ENDPROC:REM Must be a temperature sensor 670 T=FN_1W_READ_TEMP(ID):IF T=-999 ENDPROC:REM Read sensor and check for error 680 @%=&20409:PRINT MID$(FN_1W_ID$(ID),3,12);":",T;" deg C":@%=&90A 690 ENDPROC 700 REM Retrieve a single sensor's reading 710 DEFFN_1W_READ_TEMP(ID) 720 LOCAL T 730 IF FN_1W_RESET=FALSE =-999 740 PROC_1W_PUT(&55):PROC_1W_PUTS(ID,8):REM Match ROM 750 PROC_1W_PUT(&BE):PROC_1W_GETS(SCRATCH,9):REM Read scratchpad 760 IF FN_1W_CRC_CHECK(SCRATCH,8)=FALSE =-999:REM Check CRC 770 =SCRATCH!-2DIV65536/16:REM Convert to degrees C 780 : 790 REM 1-WIRE ROUTINES 800 END 810 DEFPROC_1W_INIT 820 ow_code_size=294:DIM ow_code ow_code_size-1 830 RXE=&E1:M_RXERXD=&10 840 TXC=&E4:M_TXCITX=&08 850 SC=&400 860 FOR opt=0 TO 2 STEP 2 870 P%=ow_code 880 [OPT opt 890 .ow_buf DEFB 0 \ temporary transfer buffer 900 .ow_conf DEFB 0 \ stores last bit conflict index 910 : 920 .ow_reset 930 IN A,(RXE):AND M_RXERXD:CP M_RXERXD:SBC A,A:LD (ow_buf),A:RET NZ \ check bus is idle 940 DI:LD A,(SC+TXC):OR M_TXCITX:OUT (TXC),A \ hold bus low 950 LD B,120:DJNZ P% \ delay 960 AND NOT M_TXCITX:OUT (TXC),A \ release bus 970 LD B,18:DJNZ P% \ delay 980 IN A,(RXE):AND M_RXERXD:CP M_RXERXD:CCF:SBC A,A:LD (ow_buf),A \ sample presence 990 LD B,100:DJNZ P% \ delay 1000 EI:RET 1010 : 1020 .ow_put_carry 1030 JR C,ow_put_1 \ fall-through 1040 : 1050 .ow_put_0 1060 DI 1070 LD A,(SC+TXC):OR M_TXCITX:OUT (TXC),A \ hold bus low 1080 LD B,15:DJNZ P% \ delay 1090 AND NOT M_TXCITX:OUT (TXC),A \ release bus 1100 NOP \ delay 1110 EI:RET 1120 : 1130 .ow_put_1 1140 DI 1150 LD A,(SC+TXC):OR M_TXCITX:OUT (TXC),A \ hold bus low 1160 NOP \ delay 1170 AND NOT M_TXCITX:OUT (TXC),A \ release bus 1180 PUSH HL:POP HL \ delay 1190 IN A,(RXE):AND M_RXERXD:SUB M_RXERXD:CCF \ sample bit 1200 LD A,(ow_buf):RRA:LD (ow_buf),A \ store bit 1210 LD B,7:DJNZ P% \ delay 1220 EI:RET 1230 : 1240 .ow_put_byte 1250 LD C,A:LD B,8 \ value to send in C, send 8 bits 1260 .ow_put_loop 1270 SRL C:PUSH BC:CALL ow_put_carry:POP BC \ shift and send single bit 1280 DJNZ ow_put_loop \ loop 1290 RET 1300 : 1310 .ow_put_bytes 1320 LD A,B:OR C:RET Z:DEC BC \ have we finished? 1330 LD A,(HL):INC HL \ fetch 1340 PUSH BC:CALL ow_put_byte:POP BC:JR ow_put_bytes \ send and loop 1350 : 1360 .ow_get_byte 1370 LD B,8 \ 8 bits to receive 1380 .ow_get_loop 1390 PUSH BC:CALL ow_put_1:POP BC \ receive single bit 1400 DJNZ ow_get_loop \ loop 1410 LD A,(ow_buf):RET \ store 1420 : 1430 .ow_get_bytes 1440 LD A,B:OR C:RET Z:DEC BC \ have we finished? 1450 PUSH BC:CALL ow_get_byte:POP BC \ get a byte 1460 LD (HL),A:INC HL:JR ow_get_bytes \ store and loop 1470 : 1480 .ow_search 1490 LD DE,(ow_conf):LD D,0:LD C,1:LD B,64 1500 .ow_search_loop 1510 PUSH BC:CALL ow_put_1:CALL ow_put_1:POP BC:RLCA:RLCA \ get bit, !bit 1520 AND 3:JR Z,ow_search_conf \ 00 = conflict 1530 DEC A:JR Z,ow_search_1 \ 01 = 0 bit 1540 DEC A:JR Z,ow_search_0 \ 10 = 1 bit 1550 SCF:RET \ report failure 1560 .ow_search_conf 1570 LD A,B:CP E \ how does bit index compare to last conflict 1580 JR C,ow_search_0_conf \ 0, update current discrepancy 1590 JR Z,ow_search_1 \ 1, no update 1600 LD A,(HL):AND C:JR NZ,ow_search_advance \ old bit = 1, just advance 1610 LD D,B:JR ow_search_advance \ old bit = 0, update current discrepancy 1620 .ow_search_1:LD A,C:OR (HL):LD (HL),A:JR ow_search_advance 1630 .ow_search_0_conf:LD D,B \ fall-through 1640 .ow_search_0:LD A,C:CPL:AND (HL):LD (HL),A \ fall-through 1650 .ow_search_advance 1660 LD A,(HL):AND C:SUB C:CCF:PUSH BC:CALL ow_put_carry:POP BC \ return the ID bit 1670 RLC C:JR NC,P%+3:INC HL \ advance mask 1680 DJNZ ow_search_loop 1690 LD A,D:LD (ow_conf),A 1700 XOR A:LD (ow_buf),A:RET \ report success 1710 : 1720 .ow_crc 1730 LD B,8:LD DE,(ow_buf):LD D,A \ E = accumulated CRC, D = value to add 1740 .ow_crc_loop 1750 LD A,E:XOR D:SRL D:SRL A:JR C,ow_crc_odd \ XOR and shift bits 1760 SRL E:DJNZ ow_crc_loop:LD A,E:LD (ow_buf),A:RET \ even CRC value 1770 .ow_crc_odd:SRL E:LD A,&8C:XOR E:LD E,A:DJNZ ow_crc_loop:LD (ow_buf),A:RET \ odd CRC value 1780 : 1790 .ow_crc_block 1800 XOR A:LD (ow_buf),A \ reset CRC 1810 .ow_crc_block_loop 1820 LD A,B:OR C:LD A,(ow_buf):RET Z:DEC BC \ have we finished? 1830 LD A,(HL):INC HL:PUSH BC:CALL ow_crc:POP BC:JR ow_crc_block_loop \ update CRC 1840 ] 1850 NEXT 1860 IF P%-ow_code<>ow_code_size PRINT"Code size: "P%-ow_code:END 1870 ENDPROC 1880 : 1890 REM Resets bus, retuns TRUE if any devices are present 1900 DEFFN_1W_RESET:CALL ow_reset:=?ow_buf=0 1910 REM Transmits a single byte 1920 DEFPROC_1W_PUT(A%)CALL ow_put_byte:ENDPROC 1930 REM Transmits a block of bytes 1940 DEFPROC_1W_PUTS(L%,C%)LOCAL H%,B%:H%=L%DIV256:B%=C%DIV256:CALL ow_put_bytes:ENDPROC 1950 REM Receives a single byte 1960 DEFFN_1W_GET:CALL ow_get_byte:=?ow_buf 1970 REM Receives a block of bytes 1980 DEFPROC_1W_GETS(L%,C%)LOCAL H%,B%:H%=L%DIV256:B%=C%DIV256:CALL ow_get_bytes:ENDPROC 1990 : 2000 REM Starts enumerating devices on the bus 2010 DEFPROC_1W_SEARCH_RESET:?ow_conf=TRUE:ENDPROC 2020 REM Searches for next device on bus. Pass search type &F0 for all devices, &EC for alarming devices. Returns TRUE if next device found 2030 DEFFN_1W_SEARCH(A%,ID)IF ?ow_conf=0:=FALSE ELSE IF FN_1W_RESET=0:=FALSE ELSE PROC_1W_PUT(A%):H%=ID DIV256:L%=ID:CALL ow_search:=?ow_buf=0 2040 : 2050 REM Converts ID bytes into string 2060 DEFFN_1W_ID$(ID)LOCAL I%:S$="":FOR I%=7 TO 0 STEP -1:IF ID?I%>15:S$=S$+STR$~(ID?I%):NEXT:=S$:ELSE:S$=S$+"0"+STR$~(ID?I%):NEXT:=S$ 2070 REM Converts string into ID bytes 2080 DEFPROC_1W_ID$(ID,ID$)LOCAL I%:FOR I%=0 TO 7:ID?I%=EVAL("&"+MID$(ID$,15-I%*2,2)):NEXT:ENDPROC 2090 : 2100 REM Calculates the CRC of a block of data 2110 DEFFN_1W_CRC(L%,C%)LOCAL H%,B%:H%=L% DIV256:B%=C%DIV256:CALL ow_crc_block:=?ow_buf 2120 REM Checks if a CRC at the end of a block of data matches 2130 DEFFN_1W_CRC_CHECK(L%,C%)=FN_1W_CRC(L%,C%)=(L%?C%)
Temperature logger
All of this can be put together into a program that logs the temperature from any connected sensors to a CSV file on the Z88. The main loop can look similar to the one above that searches for and displays the temperature readings for any connected DS18B20 sensors, however it will instead call a PROC_1W_LOG_TEMP procedure that handles logging the data to a file instead of printing it on the display:
REM Log a single sensor's reading DEFPROC_1W_LOG_TEMP(ID) LOCAL T IF ID?0<>&28 ENDPROC:REM Must be a temperature sensor T=FN_1W_READ_TEMP(ID):IF T=-999 ENDPROC:REM Read sensor and check for error ENTRY$=FN_DATETIME$(TIME$)+","+STR$T:REM Timestamp and temperature reading ID$=MID$(FN_1W_ID$(ID),3,12):REM ID without CRC and family code CSV$=ID$+".CSV":REM Name of CSV file C=OPENUP CSV$:REM Open the CSV for update IF C=FALSE C=OPENOUT CSV$:PRINT#C,"Time,"+ID$:REM Create new CSV if required PTR#C=EXT#C:PRINT#C,ENTRY$:REM Write entry to end of CSV CLOSE#C:REM Close the CSV PRINT CSV$,ENTRY$:REM Display on screen ENDPROC
The procedure will fetch the value from the sensor and then turn the ID into a CSV filename by stripping off the CRC and family code and appending ".CSV". It will then try to open the existing file, and if one doesn't exist it will create a new one and write the column headers to it. It will then seek to the end of the file and append the timestamp and the temperature reading.
One further complication is that to make handling the CSV a bit easier, the timestamp is converted from the format returned by BBC BASIC's TIME$ function into "YYYY-MM-DD hh:mm:ss" format. This is handled by the following three functions, FN_DATE (which extracts and reformats the date component into YYYY-MM-DD format), FN_TIME (which extracts the time component into hh:mm:ss format) and FN_DATETIME which glues the date and time back together with a space in the middle:
REM Date formatting routines DEF FN_DATE$(T$) LOCAL C%,I%,J%,V%,R$ R$="":I%=1 FOR C%=0 TO 3 J%=INSTR(MID$(T$,I%)," ") IF C%=2 V%=1+INSTR("JanFebMarAprMayJunJulAugSepOctNovDec",MID$(T$,I%,3))DIV3 ELSE V%=VAL(MID$(T$,I%,J%-1)) IF C%>1 R$="-"+R$ IF C% R$=STR$(V%)+R$ IF V%<10 R$="0"+R$ I%=I%+J% NEXT =R$ DEF FN_TIME$(T$) =MID$(T$,LEN(T$)-7) DEF FN_DATETIME$(T$) =FN_DATE$(T$)+" "+FN_TIME$(T$)
One way to make this logging program more useful would be to get the computer to run it periodically (e.g. once per minute). The Z88's "Alarm" feature can execute a command whenever the alarm goes off and you can schedule recurring alarms so this sounds like an ideal starting point! When the program has run it would also be handy for the computer to switch itself off again. There is an OS call for this, OS_Off, which can be invoked from BASIC as follows:
REM SWITCH OFF ROUTINES DEFPROC_SWITCH_OFF_INIT switch_off_size=15:DIM switch_off switch_off_size-1 P%=switch_off [OPT 2 LD HL,0:ADD HL,SP:LD SP,(&1FFE):PUSH HL RST &20:DEFW &EC06:REM OS_Off POP HL:LD SP,HL:RET:] ENDPROC DEFPROC_SWITCH_OFF:CALL switch_off:ENDPROC
As with the 1-Wire assembly routines you must first call an initialisation procedure (PROC_SWITCH_OFF_INIT) to assemble the routine before calling it with PROC_SWITCH_OFF. The actual OS_Off call is the RST &20H:DEFW &EC06 in the middle of all that. Unfortunately, OS calls tend to involve some memory paging and in the process BBC BASIC's RAM gets swapped out and when the OS routine tries to return it jumps back into some different memory – the computer certainly switches off, but then it soft resets instead of coming back on properly. This is why there's some additional boilerplate code around the OS call to move the stack pointer into a safe region of memory so the routine can return properly.
The complete temperature-logging program is now shown below:
10 REM 1-WIRE TEMPERATURE LOGGER : BEN RYVES 2023 20 *NAME 1-Wire Temperature Logger 30 REPEAT UNTIL INKEY(0)=TRUE:REM Flush keyboard 40 DIM ID 7,SCRATCH 8 50 PROC_1W_INIT 60 PROC_SWITCH_OFF_INIT 70 REM Reset 1-Wire bus and check that at least one device is present 80 IF FN_1W_RESET=FALSE PROC_SWITCH_OFF:END 90 REM Start temperature conversion 100 PROC_1W_PUT(&CC):REM Skip ROM 110 PROC_1W_PUT(&44):REM Convert T 120 T=TIME:IF INKEY(75)>TRUE REPEAT:UNTIL TIME>T+75:REM Delay 750ms 130 REM Search for all temperature sensors on the bus and log their readings 140 PROC_1W_SEARCH_RESET 150 REPEAT F%=FN_1W_SEARCH(&F0,ID):IF F% PROC_1W_LOG_TEMP(ID) 160 UNTIL F%=FALSE 170 REM Switch the computer off 180 PROC_SWITCH_OFF 190 END 200 : 210 REM Log a single sensor's reading 220 DEFPROC_1W_LOG_TEMP(ID) 230 LOCAL T 240 IF ID?0<>&28 ENDPROC:REM Must be a temperature sensor 250 T=FN_1W_READ_TEMP(ID):IF T=-999 ENDPROC:REM Read sensor and check for error 260 ENTRY$=FN_DATETIME$(TIME$)+","+STR$T:REM Timestamp and temperature reading 270 ID$=MID$(FN_1W_ID$(ID),3,12):REM ID without CRC and family code 280 CSV$=ID$+".CSV":REM Name of CSV file 290 C=OPENUP CSV$:REM Open the CSV for update 300 IF C=FALSE C=OPENOUT CSV$:PRINT#C,"Time,"+ID$:REM Create new CSV if required 310 PTR#C=EXT#C:PRINT#C,ENTRY$:REM Write entry to end of CSV 320 CLOSE#C:REM Close the CSV 330 PRINT CSV$,ENTRY$:REM Display on screen 340 ENDPROC 350 REM Retrieve a single sensor's reading 360 DEFFN_1W_READ_TEMP(ID) 370 LOCAL T 380 IF FN_1W_RESET=FALSE =-999 390 PROC_1W_PUT(&55):PROC_1W_PUTS(ID,8):REM Match ROM 400 PROC_1W_PUT(&BE):PROC_1W_GETS(SCRATCH,9):REM Read scratchpad 410 IF FN_1W_CRC_CHECK(SCRATCH,8)=FALSE =-999:REM Check CRC 420 =SCRATCH!-2DIV65536/16:REM Convert to degrees C 430 : 440 REM Date formatting routines 450 DEF FN_DATE$(T$) 460 LOCAL C%,I%,J%,V%,R$ 470 R$="":I%=1 480 FOR C%=0 TO 3 J%=INSTR(MID$(T$,I%)," ") 490 IF C%=2 V%=1+INSTR("JanFebMarAprMayJunJulAugSepOctNovDec",MID$(T$,I%,3))DIV3 ELSE V%=VAL(MID$(T$,I%,J%-1)) 500 IF C%>1 R$="-"+R$ 510 IF C% R$=STR$(V%)+R$ IF V%<10 R$="0"+R$ 520 I%=I%+J% 530 NEXT 540 =R$ 550 DEF FN_TIME$(T$) =MID$(T$,LEN(T$)-7) 560 DEF FN_DATETIME$(T$) =FN_DATE$(T$)+" "+FN_TIME$(T$) 570 : 580 REM SWITCH OFF ROUTINES 590 DEFPROC_SWITCH_OFF_INIT 600 switch_off_size=15:DIM switch_off switch_off_size-1 610 P%=switch_off 620 [OPT 2 630 LD HL,0:ADD HL,SP:LD SP,(&1FFE):PUSH HL 640 RST &20:DEFW &EC06 650 POP HL:LD SP,HL:RET:] 660 ENDPROC 670 DEFPROC_SWITCH_OFF:CALL switch_off:ENDPROC 680 : 690 REM 1-WIRE ROUTINES 700 END 710 DEFPROC_1W_INIT 720 ow_code_size=294:DIM ow_code ow_code_size-1 730 RXE=&E1:M_RXERXD=&10 740 TXC=&E4:M_TXCITX=&08 750 SC=&400 760 FOR opt=0 TO 2 STEP 2 770 P%=ow_code 780 [OPT opt 790 .ow_buf DEFB 0 \ temporary transfer buffer 800 .ow_conf DEFB 0 \ stores last bit conflict index 810 : 820 .ow_reset 830 IN A,(RXE):AND M_RXERXD:CP M_RXERXD:SBC A,A:LD (ow_buf),A:RET NZ \ check bus is idle 840 DI:LD A,(SC+TXC):OR M_TXCITX:OUT (TXC),A \ hold bus low 850 LD B,120:DJNZ P% \ delay 860 AND NOT M_TXCITX:OUT (TXC),A \ release bus 870 LD B,18:DJNZ P% \ delay 880 IN A,(RXE):AND M_RXERXD:CP M_RXERXD:CCF:SBC A,A:LD (ow_buf),A \ sample presence 890 LD B,100:DJNZ P% \ delay 900 EI:RET 910 : 920 .ow_put_carry 930 JR C,ow_put_1 \ fall-through 940 : 950 .ow_put_0 960 DI 970 LD A,(SC+TXC):OR M_TXCITX:OUT (TXC),A \ hold bus low 980 LD B,15:DJNZ P% \ delay 990 AND NOT M_TXCITX:OUT (TXC),A \ release bus 1000 NOP \ delay 1010 EI:RET 1020 : 1030 .ow_put_1 1040 DI 1050 LD A,(SC+TXC):OR M_TXCITX:OUT (TXC),A \ hold bus low 1060 NOP \ delay 1070 AND NOT M_TXCITX:OUT (TXC),A \ release bus 1080 PUSH HL:POP HL \ delay 1090 IN A,(RXE):AND M_RXERXD:SUB M_RXERXD:CCF \ sample bit 1100 LD A,(ow_buf):RRA:LD (ow_buf),A \ store bit 1110 LD B,7:DJNZ P% \ delay 1120 EI:RET 1130 : 1140 .ow_put_byte 1150 LD C,A:LD B,8 \ value to send in C, send 8 bits 1160 .ow_put_loop 1170 SRL C:PUSH BC:CALL ow_put_carry:POP BC \ shift and send single bit 1180 DJNZ ow_put_loop \ loop 1190 RET 1200 : 1210 .ow_put_bytes 1220 LD A,B:OR C:RET Z:DEC BC \ have we finished? 1230 LD A,(HL):INC HL \ fetch 1240 PUSH BC:CALL ow_put_byte:POP BC:JR ow_put_bytes \ send and loop 1250 : 1260 .ow_get_byte 1270 LD B,8 \ 8 bits to receive 1280 .ow_get_loop 1290 PUSH BC:CALL ow_put_1:POP BC \ receive single bit 1300 DJNZ ow_get_loop \ loop 1310 LD A,(ow_buf):RET \ store 1320 : 1330 .ow_get_bytes 1340 LD A,B:OR C:RET Z:DEC BC \ have we finished? 1350 PUSH BC:CALL ow_get_byte:POP BC \ get a byte 1360 LD (HL),A:INC HL:JR ow_get_bytes \ store and loop 1370 : 1380 .ow_search 1390 LD DE,(ow_conf):LD D,0:LD C,1:LD B,64 1400 .ow_search_loop 1410 PUSH BC:CALL ow_put_1:CALL ow_put_1:POP BC:RLCA:RLCA \ get bit, !bit 1420 AND 3:JR Z,ow_search_conf \ 00 = conflict 1430 DEC A:JR Z,ow_search_1 \ 01 = 0 bit 1440 DEC A:JR Z,ow_search_0 \ 10 = 1 bit 1450 SCF:RET \ report failure 1460 .ow_search_conf 1470 LD A,B:CP E \ how does bit index compare to last conflict 1480 JR C,ow_search_0_conf \ 0, update current discrepancy 1490 JR Z,ow_search_1 \ 1, no update 1500 LD A,(HL):AND C:JR NZ,ow_search_advance \ old bit = 1, just advance 1510 LD D,B:JR ow_search_advance \ old bit = 0, update current discrepancy 1520 .ow_search_1:LD A,C:OR (HL):LD (HL),A:JR ow_search_advance 1530 .ow_search_0_conf:LD D,B \ fall-through 1540 .ow_search_0:LD A,C:CPL:AND (HL):LD (HL),A \ fall-through 1550 .ow_search_advance 1560 LD A,(HL):AND C:SUB C:CCF:PUSH BC:CALL ow_put_carry:POP BC \ return the ID bit 1570 RLC C:JR NC,P%+3:INC HL \ advance mask 1580 DJNZ ow_search_loop 1590 LD A,D:LD (ow_conf),A 1600 XOR A:LD (ow_buf),A:RET \ report success 1610 : 1620 .ow_crc 1630 LD B,8:LD DE,(ow_buf):LD D,A \ E = accumulated CRC, D = value to add 1640 .ow_crc_loop 1650 LD A,E:XOR D:SRL D:SRL A:JR C,ow_crc_odd \ XOR and shift bits 1660 SRL E:DJNZ ow_crc_loop:LD A,E:LD (ow_buf),A:RET \ even CRC value 1670 .ow_crc_odd:SRL E:LD A,&8C:XOR E:LD E,A:DJNZ ow_crc_loop:LD (ow_buf),A:RET \ odd CRC value 1680 : 1690 .ow_crc_block 1700 XOR A:LD (ow_buf),A \ reset CRC 1710 .ow_crc_block_loop 1720 LD A,B:OR C:LD A,(ow_buf):RET Z:DEC BC \ have we finished? 1730 LD A,(HL):INC HL:PUSH BC:CALL ow_crc:POP BC:JR ow_crc_block_loop \ update CRC 1740 ] 1750 NEXT 1760 IF P%-ow_code<>ow_code_size PRINT"Code size: "P%-ow_code:END 1770 ENDPROC 1780 : 1790 REM Resets bus, retuns TRUE if any devices are present 1800 DEFFN_1W_RESET:CALL ow_reset:=?ow_buf=0 1810 REM Transmits a single byte 1820 DEFPROC_1W_PUT(A%)CALL ow_put_byte:ENDPROC 1830 REM Transmits a block of bytes 1840 DEFPROC_1W_PUTS(L%,C%)LOCAL H%,B%:H%=L%DIV256:B%=C%DIV256:CALL ow_put_bytes:ENDPROC 1850 REM Receives a single byte 1860 DEFFN_1W_GET:CALL ow_get_byte:=?ow_buf 1870 REM Receives a block of bytes 1880 DEFPROC_1W_GETS(L%,C%)LOCAL H%,B%:H%=L%DIV256:B%=C%DIV256:CALL ow_get_bytes:ENDPROC 1890 : 1900 REM Starts enumerating devices on the bus 1910 DEFPROC_1W_SEARCH_RESET:?ow_conf=TRUE:ENDPROC 1920 REM Searches for next device on bus. Pass search type &F0 for all devices, &EC for alarming devices. Returns TRUE if next device found 1930 DEFFN_1W_SEARCH(A%,ID)IF ?ow_conf=0:=FALSE ELSE IF FN_1W_RESET=0:=FALSE ELSE PROC_1W_PUT(A%):H%=ID DIV256:L%=ID:CALL ow_search:=?ow_buf=0 1940 : 1950 REM Converts ID bytes into string 1960 DEFFN_1W_ID$(ID)LOCAL I%:S$="":FOR I%=7 TO 0 STEP -1:IF ID?I%>15:S$=S$+STR$~(ID?I%):NEXT:=S$:ELSE:S$=S$+"0"+STR$~(ID?I%):NEXT:=S$ 1970 REM Converts string into ID bytes 1980 DEFPROC_1W_ID$(ID,ID$)LOCAL I%:FOR I%=0 TO 7:ID?I%=EVAL("&"+MID$(ID$,15-I%*2,2)):NEXT:ENDPROC 1990 : 2000 REM Calculates the CRC of a block of data 2010 DEFFN_1W_CRC(L%,C%)LOCAL H%,B%:H%=L% DIV256:B%=C%DIV256:CALL ow_crc_block:=?ow_buf 2020 REM Checks if a CRC at the end of a block of data matches 2030 DEFFN_1W_CRC_CHECK(L%,C%)=FN_1W_CRC(L%,C%)=(L%?C%)
When run this will log the temperatures of all connected sensors to CSV files as described above then switch the Z88 off. The "Alarm" popdown can be used to set up an alarm that runs the program once per minute (or at any other desired interval) by choosing an alarm type of "execute". This will effectively type in the supplied command, and so by setting it to #BRUN"TEMPLOG.BBC"~E it will press □+B to switch to BASIC (#B), type in RUN"TEMPLOG" and then press Enter (~E).
Setting up the alarm this way each time can be a bit tedious, so to make things easier here's a CLI file that can be used to set up the alarm:
.;Set up temperature logging alarm #A ~R~E ~D~D ##BRUN"TEMPLOG.BBC"~~E ~D ~D~R~D~R~S~U~S~U~R~D
This contains keystrokes in a similar fashion to the "command" field in the alarm settings and can be "executed" from the Z88's Filer; here #A presses □+A to enter the Alarm pop-down, ~R, ~D or ~U move the cursor right, down or up and where we need to type literal # or ~ signs they are doubled up (## or ~~). This will enter all of the required details to set up an alarm that will run the task once per minute forever, at which point they can be adjusted if required (e.g. to change the interval). Pressing Enter will create the alarm, and leaving the Alarm popdown will set it in motion. To finish data collection the Z88 can be switched back on as normal for the alarm to be cleared.
The only other point of note is that I found that the computer seemed to get a bit "gummed up" with queued keypresses. This could be because it never sits idle after handling the alarm; it runs the BASIC program then switches the computer off, waiting for the next alarm to be run. This is why a simple loop to flush the keyboard buffer occurs at the start of the program, and the computer seems much happier for it.
The temperature logs in the CSV files can be used to generate a chart like the following:
I captured data from three sensors over a 24 hour period; one outside (green line), one in my bedroom (red line) and one in my office (blue line). You can see how the central heating kicks in at 07:30, and I turned it up a little after 12:00. During the day the temperature in the bedroom moves up and down as the heating switches on and off, but the temperature in the office appears to be more consistent and a bit higher – the sensor is near where I am sitting and my desktop computer, which is likely contributing some heat.
Conclusion
What was originally intended to be a quick project to make use a couple of electronic components I had been sent in error soon turned into what I thought was an interesting demonstration of what can be done with the Cambridge Z88 using its stock software and some very basic additional hardware, further cementing my appreciation for the well-designed device.
The files accompanying this post can be downloaded below:
- ONEWIRE.BBC – 1-Wire demonstration program.
- TEMPLOG.BBC and TEMPLOG.CLI – Temperature logging program and CLI file to set up the alarm.
- templogs.zip and templogs.xlsx – Sample data captured by the temperature logging program.
SmartBox experimentation with DOS, RISC OS and C#/WinForms
Monday, 6th November 2023
My latest eBay purchase was influenced by a desire for some practical test/prototype equipment, a bit of nostalgia and a desire to learn something new.
A lot of my projects involve some sort of microcontroller running some software that will take inputs, perform decisions on them, and produce outputs. Getting to that stage tends to involve quite a lot of "boilerplate" hardware and software setup, and I'd quite like something that I can just plug in and get cracking with and write some quick test code instead of having to assemble a circuit on a breadboard or faff around with a clumsy IDE.
When I was at school in the 1990s one of the devices that got me into microcontrollers in the first place was a computer control system based around the Economatics SmartBox. This plugs into a computer via a serial connection, has eight simple digital inputs, eight simple digital outputs, four analogue inputs and four motor drivers. Programs could be written in a BASIC-like language or in flowchart form, and once you'd run and tested them on the SmartBox you could program them to a PIC microcontroller to run without the host computer.
All along I'd assumed that the SmartBox was a simple interface box that relied on the host computer to do all of the processing, but doing some digging I found a thread on StarDot that delved into the heart of the machine and saw that there's a 65C02 CPU inside along with 32KB of RAM and the OS runs from a socketed 8KB ROM. As a long-term Z80 fan I thought it was time I should see how the other side lived – in spite of my fondness for the BBC Micro I don't own one and have not programmed any 6502 assembly, so a SmartBox seemed like it would also provide an affordable 6502 computer for experimentation.
Of course, one challenge was going to be finding the supporting software for the venerable SmartBox. Fortunately in the StarDot thread people had shared archives of the DOS, BBC Master and RISC OS software. One of the many handy features of DOSBox-X is its ability to connect an emulated serial port to a physical one in the host system, and so after building a serial cable for my SmartBox (using a pinout found, once again, via the StarDot thread) I was able to hook it up to my PC and get it working with SmartMove, the BASIC-like programming environment for the SmartBox.
When the software is first run it needs to download SmartMove code into the SmartBox. This is because the programming environment and interpreter is actually running on the SmartBox itself, and the SmartMove software on the host PC is simply loading that interpreter onto the box (found in an accompanying file of 65C02 machine code named AL.COD) and then providing a user interface to that environment as a sort of terminal. This means you can close the SmartMove software (and unplug the serial cable) and your program will continue running on the SmartBox.
This ability to load and execute code directly on the SmartBox is one of the things that intrigued me as a way to get into 65C02 programming, but for the time being I was interested in digging deeper into the how the existing SmartMove software was interfacing with the box with the intention of writing a simple Z80 host interface that I could then adapt to the Cambridge Z88, my CP/M computer and maybe even the TI-83 Plus calculator series. Fortunately the documentation for the serial protocol used by SmartMove application has been documented so I was able to prototype a crude version of the software in C# using WinForms. It needs some serious tidying up before I can release it but as a basic test it does the job:
The ability to build new versions of the interface software for different platforms without needing to worry about porting over the BASIC interpreter seems sensible considering there were versions of SmartMove available for DOS, BBC Master, RISC OS and Apple Macintosh. All could use the same AL.COD but would just need to provide the relevant UI, input and output routines specific to their host platforms.
I had been using the DOS version of SmartMove as the initial inspiration of the user interface for my C#/WinForms implementation, however the screenshots of the RISC OS version in the user manual looked rather more visually appealing and an archived copy of this software was available. Unfortunately, I don't own an Acorn Archimedes, I was unable to get the software running properly on modern RISC OS on a Raspberry Pi (even with various compatibility shims in place) and I couldn't find an emulator that handled the serial port in a similar fashion to DOSBox-X. However, the excellent Arculator has source code available and armed with a copy of the 6651 UART datasheet I thought I'd have a go at hacking in the feature myself.
"Hack" is definitely the operative word and though my code is abominable it does work well enough to get the available RISC OS software working on my PC. It's downloadable from here and requires the addition of the host PC's serial port name to arc.cfg (e.g. serial_port = COM1).
The above screenshot shows the RISC OS version of SmartMove which provided some additional inspiration for how a GUI version of the software should work. As I'd previously loaded some procedures onto the SmartBox via my C# version I could then bring the same procedures up for editing in the RISC OS version of the software. Handy!
The Logicator software can be used to build programs using flowcharts instead of a BASIC-like programming language. As far as I'm aware Logicator directly accesses the inputs and outputs from a number of different host interface boxes and doesn't rely on 65C02 code loaded onto the box like SmartMove, but this does mean that when you close Logicator your program stops aas it's relying on the host PC to run the show. However, included with the archive of RISC OS software is an application called SmartFlow which first loads a flow chart "interpreter" into the SmartBox:
Once loaded you can then load a Logicator-format flowchart onto the SmartBox where it can be run without being connected to a host PC:
All in all it's been quite interesting to dig into the SmartBox and get a feel for how it works and what can be done with it. To this end I recorded a video demonstration of the SmartBox and its usage within RISC OS, though so far I feel I'm only really scratching the surface!
Adding 11KB of RAM to a CP/M 3 system with a single NAND gate chip
Wednesday, 30th August 2023
It's been quite a while since I posted about my Z80 Computer project. This is a home-made Z80 computer I built back in 2010 that features a 10MHz Z80 CPU with 64KB RAM that runs CP/M 3. It can drive an internal LCD, TV or VGA monitor at 320x240 (monochrome only) and unfortunately is a project I was never too happy with due to several compromises I had to make in its design – though at the time I was happy enough I got it to work at all! The video output was limited by both my choice to use an internal graphical LCD and the limitations of the dsPIC33F I chose to use to drive it and the software was all a bit half-baked. I could run the generic CP/M version of BBC BASIC on it, but this lacks graphics and sound support, for example.
More recently my work on adapting BBC BASIC to the Sega Master System had reignited my interest in 8-bit programming, though that too was imperfect due to the limitations of the Master System's VDP. I was further encouraged by coming third in the "Retro not Vintage" competition on /r/retrobattlestations, though I'm not sure I was quite worthy of a podium finish.
With this in mind I started work on improving the computer. I replaced the existing dsPIC33F VDP with a new one based around a dsPIC33E. This newer microcontroller has 32KB of RAM and can run at up to 70 MIPS, a big upgrade from the previous 16KB RAM and 40 MIPS. This provides me with enough video RAM to store the largest BBC Micro screen mode frame buffer (20KB) as well as the necessary CPU grunt to look up pixel data from colour palettes and output it to the screen. I've implemented all eight of the standard BBC Micro screen modes, from the high-res 640x256 (in two colours) MODE 0 to the low-res 160x256 (in sixteen colours) MODE 2 along with the Teletext-compatible MODE 7. This is all controlled via a BBC Micro-compatible VDU driver and the results all seem quite faithful with no real compromises.
There was even enough CPU power left over on the microcontroller to implement BBC Micro-compatible SOUND and ENVELOPE, and with the source code for the CP/M version of BBC BASIC having been released since I last worked on the project it made it much easier to add all of the graphics and sound routines into the version of BBC BASIC specific to my computer.
To get an idea of what the computer is like to use, I recorded a little demo video here. However, this is not really what I wanted to write about in this post – I wanted to cover an easy way to free up some RAM by implementing banked CP/M 3.
Non-banked versus banked CP/M
I chose CP/M 3 as the OS for my computer instead of CP/M 2 as I'm using an SD card for storage and CP/M 3 has native support for disk sector sizes that do not directly match the file record size and it will handle the blocking/unblocking for you (CP/M's file records are 128 bytes long, SD card sectors are 512 bytes long). One other nice feature of CP/M 3 is the existence of a "banked" version which allows it to run on systems with more than 64KB of RAM. As far as user programs are concerned they still run in a flat 64KB memory space, however the OS can move certain parts of itself as well as disk and directory buffers into a separate memory bank where they are only accessed when needed, freeing up space in the "transient program area" (TPA). As well as more memory for user programs the banked version provides a much improved line editor when typing at the command-line, password protection of files and more descriptive error messages.
Naturally, when I read about this I thought it would be an obvious choice for my computer. As it is, I'm using a 128KB RAM chip but have tied A16 low as I didn't have any kind of MMU or bank-switching hardware setup (32KB and 128KB RAM chips are available in abundance, 64KB ones less so, and using a 128KB chip with the address line tied low involved a lot less soldering than two separate 32KB RAM chips). I did have an emulator where I could try to prototype the hardware changes to support a banked CP/M 3, however I was not able to get a banked version of the OS built and working so gave up – after all, I had a 49KB TPA, which seemed like it would be good enough.
With the other improvements to the computer recently I thought it worth reinvestigating. I did a bit of hunting to see if I could find any recommendations for a simple setup but most of what I could find ended up being a lot more complicated than what I was really looking for. After a bit more experimentation I was able to end up with a banked version of CP/M running on my computer and all I needed was a single NAND gate chip.
Memory requirements for banked CP/M
The memory layout of banked CP/M is actually quite a bit simpler than a lot of the threads I could find online seemed to make out. All you really need is a shared common area at the top of memory that will always be accessible regardless of the current state of the selected bank, and memory below that which can be switched between multiple banks. When booting the computer bank 0 will be selected, so both the common (resident) and banked parts can be copied to memory, and then bank 1 will be swapped in to provide the large TPA.
In my case, as I'm using a 128KB RAM chip, I will use A16 as the bank selection bit. When low this will provide access to the lower 64KB RAM on the chip, when high it will provide access to the upper 64KB RAM. To implement the common area at the top of memory, you then just need to check to see if the address is above the boundary between banked and common memory and if so to force A16 either high or low (it doesn't matter which, as long as it's consistent) so that when the address is in the common area the same bank will be accessed, regardless of the state of the bank selection bit.
Bank switching with simple logic
A simple way to implement a common area in upper memory is with AND (to detect the high address) and OR (to force the A16 high if it's a high address) logic, like this:
Here we use a 4-input AND gate to detect any memory address in the top 4KB of the chip (address lines A12 to A15 will go high at %1111000000000000 which gives a common region of $F000 to $FFFF). If that's the case, then the output of the 4-input AND gate will be high, which when ORed with the bank selection bit will force A16 high whenever we're in the common memory area. If we're below the common memory area then the value of the bank selection bit will pass through directly to A16, allowing us to bank switch the lower area of memory. Or, to summarise in a truth table:
In | Out | ||||
---|---|---|---|---|---|
A12 | A13 | A14 | A15 | BANK | A16 |
1 | 1 | 1 | 1 | x | 1 |
0 | x | x | x | 0 | 0 |
0 | x | x | x | 1 | 1 |
x | 0 | x | x | 0 | 0 |
x | 0 | x | x | 1 | 1 |
x | x | 0 | x | 0 | 0 |
x | x | 0 | x | 1 | 1 |
x | x | x | 0 | 0 | 0 |
x | x | x | 0 | 1 | 1 |
However, it would be easier if we could implement this on a single chip. A 4x 2-input NAND gate chip (such as the SN74ALS00AN) should do the job when wired up as follows:
The truth table is a little different this time around:
In | Out | |||
---|---|---|---|---|
A13 | A14 | A15 | BANK | A16 |
1 | 1 | 1 | x | 1 |
0 | x | x | 0 | 1 |
0 | x | x | 1 | 0 |
x | 0 | x | 0 | 1 |
x | 0 | x | 1 | 0 |
x | x | 0 | 0 | 1 |
x | x | 0 | 1 | 0 |
When accessing the banked region of memory A16 is the inverse of the bank selection bit. This doesn't matter, though, as long as there's a consistent mapping between logical addresses and the physical RAM addresses it will work even if it's "backwards". There's also one fewer address line, which means that the common area now runs from %1110000000000000 = $E000 to $FFFF, providing a common area of 8KB. In practice I didn't find this made a difference to the amount of memory available in the TPA; whether the common area was 4KB, 8KB or 16KB I was able to bring the TPA up to 60KB (from 49KB in the non-banked system), though it does eat into the amount of memory available on page 0 for disk and directory buffers. As I'm loading from an SD card (which is much faster than the floppy discs of yore) the reduced buffer space is less of a concern to me.
Fortunately there was enough space inside the computer (and a single remaining pin on the I/O controller to act thas bank selection bit) to add the NAND chip and drive A16. At last I have access to 120KB of my 128KB RAM chip... but what about the software?
Building a banked version of CP/M
I will start with the assumption that you have been able to build a non-banked version of CP/M 3 and got that running on your computer, as there is a lot less that can go wrong when doing so. Once you've got that working there's not too much to add to your BIOS to make it support banking, however I did run into a few issues with missing files and some misinterpretation of how things should work until I was able to get it working.
I used the "Developers Build Directory for CP/M 3" from The Unofficial CP/M Web site as my source for CP/M 3. This contains the GENCPM tool that will be used to generate the CPM3.SYS that will need to be loaded into memory by your boot loader. In my case I get my I/O controller to copy CP/M from the SD card into memory at boot – if you've already got the non-banked version of CP/M 3 booting then you'll be familiar with this, but do pay attention to table D-1 in the CP/M 3 system guide which points out the two parts of CP/M to load – the "resident" and "banked" portions. Both parts need to be loaded on a banked system, and both need to be loaded into page 0.
To get that far you will need to have relocatable copies of your banked BIOS (BNKBIOS3.SPR) and the BDOS (RESBDOS3.SPR and BNKBDOS3.SPR) ready to be used by GENCPM. I couldn't find a ready-made copy of these BDOS modules, but you can build them using RMAC and LINK as shown below:
RMAC RESBDOS LINK RESBDOS3=RESBDOS[OS]
PIP BNKBDOS3.ASM=CPMBDOS2.ASM,CONBDOS.ASM,BDOS30.ASM RMAC BNKBDOS3 LINK BNKBDOS3=BNKBDOS3[OS]
The banked BDOS source code is split between three different source files which need to be combined with PIP first, then can be built. For the sake of completeness, if you wanted to build the non-banked BDOS3.SPR you'd use a very similar set of commands, just with CPMBDOS1.ASM instead of CPMBDOS2.ASM:
PIP BDOS3.ASM=CPMBDOS1.ASM,CONBDOS.ASM,BDOS30.ASM RMAC BDOS3 LINK BDOS3=BDOS3[OS]
The other important ingredient is your banked BIOS, BNKBIOS3.SPR. I don't get on with 8080 syntax so I assemble my BIOS3.MAC with Microsoft's M80 in Z80 mode (instead of RMAC).
RMAC SCB RMAC BIOSKRNL M80 =BIOS3 LINK BNKBIOS3[B]=BIOSKRNL,BIOS3,SCB
If you had previously edited BIOSKRNL.ASM to state banked equ false remember to change it to banked equ true as well!
The only additions you should need in your BIOS are implementations of ?xmove and ?bank. ?bank is an easy one, and just switches to the memory bank requested in the A register. In my case I handle that just by outputting A to the I/O port that handles bank switching:
; Select Memory Bank ; Entry Parameters: A=Memory Bank ; Returned Values: None ; You must preserve or restore all registers other than the ; accumulator, A, upon exit. ?bank: if banked out (bank$select),a ; change this for what your hardware requires endif ret
(To retain compatibility with my old banked BIOS I wrap the changes in an if banked condition – banked equ true appears earlier in the file).
?xmove is a little more complicated – this states that the subsequent ?move operation (which copies BC bytes from DE to HL) should transfer data from one memory bank to another. Note that this only affects the next ?move operation; if ?move is called again afterwards without ?xmove then it should perform a copy within the same bank as before.
Fortunately the inter-bank copy is limited to 128 bytes so you can simply implement this by temporarily copying the data from one bank into a 128 byte buffer in common memory, then copying the data back to the destination bank. It's not exactly efficient, but it keeps the hardware simple.
; Memory-to-Memory Block Move ; Entry Parameters: HL=Destination address ; DE=Source address ; BC=Count ; Returned Values: HL and DE must point to ; next bytes following move operation ?move: ex de,hl ldir ex de,hl ret ; Set Banks for Following MOVE ; Entry Parameters: B=destination bank ; C=source bank ; Returned Values: None ?xmove: if banked ; Store the source/destination bank numbers ld (mov$src$b),bc ; Make sure that the next call to move (via ?mov vector) uses the banked move routine. ld bc,banked$move ld (?mov+1),bc ret banked$move: ; Select source bank ld a,(mov$src$b) call ?bank ; Swap registers from CP/M to Z80 conventions ex de,hl ; Preserve destination and length push de push bc ; Copy from source to buffer ld de,mov$buf ldir ; Recover length and destination, preserve source pop bc pop de push hl ; Select destination bank ld a,(mov$dst$b) call ?bank ; Copy from buffer to destination ld hl,mov$buf ldir ; Recover source pop hl ; Swap registers from Z80 to CP/M conventions ex de,hl ; Make sure that the next call to move (via ?mov vector) uses the regular move routine. ld bc,?move ld (?mov+1),bc ret mov$src$b: db 0 mov$dst$b: db 0 mov$buf: ds 128 else ; Unbanked ret endif
This implementation works by changing the ?mov vector in the BIOSKRNL to point at our banked$move routine after a request to ?xmove. Once we've carried out the banked move, the original ?move routine is restored to the ?mov vector.
Once you have assembled and linked your BNKBIOS3.SPR, RESBDOS3.SPR and BNKBDOS3.SPR you can use GENCPM to create your new CPM3.SYS. You'll need to answer some questions differently to support the banked system:
- Bank switched memory? Y.
- Common memory base page? E0 (if using the NAND gate circuit above – our common area starts at $E000).
- Number of memory segments? 1 – we have three in total (bank 0, bank 1 and common) however bank 1 and common are not included in the segment table so should be ignored here.
- Memory segment table base, size, bank: 01, 90, 00 (we want to keep CP/M out of the "zero page" so start the segment from $0100, CP/M 3 starts at $9100 so we have $9100-$0100=$9000 as our size, the bank number is 0).
Before being prompted for the memory segment table GENCPM will display where CP/M 3 itself is using memory so you can use that to figure out how much free space you have on your bank zero for your segment definition. However, if you enter a value that is too large GENCPM will automatically reduce the size for you.
After this you will be prompted to create disk and directory buffers for each of your disk definitions – pay attention to available space to get an idea of how many buffers you can create, but if in doubt just allocate a single buffer for each disk/directory as prompted as that will at least get you booted, then you can experiment with larger buffers later.
I did intentionally start my segment from $0100 instead of $0000 and this is to avoid problems with interrupts and to keep the zero page free. My computer design uses interrupts to signal to the Z80 that keys are available (for example) instead of requiring it to constantly poll the I/O controller. However, I did find that if I interrupted the CPU (e.g. by pressing a key) when it had switched over to page 0 it would hang the computer as the ISR vector had been switched out from underneath it. My ISR is in common memory and I just make sure that when the computer boots it installs its interrupt vectors in every memory bank so that it doesn't matter which is currently swapped in, it'll always find its way to the common ISR.
After making these changes I was greeted with a 60KB TPA instead of the previous 49KB TPA – 11KB of extra memory is well worth it, and the improved line editor in CP/M 3 is another nice bonus. I did think that implementing this was going to be a nightmare, but in the end I only needed one extra NAND gate and a few easy changes to the software.
Addendum (31st August 2023): One other change you will need to implement is to support disk operations reading from or writing to specific memory banks. I forgot to mention this earlier as it's handled by the setbnk routine inside BIOSKRNL, and that routine stores the selected DMA bank number in the @dbnk variable. When your BIOS performs a disk read or write operation it will need to preserve the current bank number, switch to the bank number in @dbnk, carry out the read or write operation, then restore the previous bank number.
In my case, disk I/O is handled by the AVR I/O controller where operations are set up by sending over the DMA address, sector and track numbers, drive index and then performing a read from either the "read" or "write" ports to initiate the I/O operation and retrieve the status. The only change required was to make sure that the bank number is also sent over before initiating the I/O request so the AVR knows which bank it should be accessing:
fd$copy$ptrs: ld hl,(@dma) ld a,l out (disk$dma$l),a ld a,h out (disk$dma$h),a ld hl,(@sect) ld a,l out (disk$sector$l),a ld a,h out (disk$sector$h),a ld hl,(@trk) ld a,l out (disk$track$l),a ld a,h out (disk$track$h),a ld a,(@adrv) out (disk$drive),a if banked ld a,(@dbnk) out (disk$dma$bank),a endif ret fd$write: call fd$copy$ptrs in a,(disk$write) ret fd$read: call fd$copy$ptrs in a,(disk$read) ret
I'm pretty sure I didn't forget anything else!
Reverse engineering Z-Tape for the Cambridge Z88
Saturday, 10th June 2023
When reading about the Cambridge Z88 computer and its available software I bumped into the occasional mention of Z-Tape by Wordmongers, a system that allowed you to back up files from your Z88 to a cassette recorder. I had wondered how this worked, assuming there some sort of external hardware to connect the cassette recorder to the Z88 (likely via its serial port). I'd done some work on tape loading and saving myself for the Sega Master System and had come up with a somewhat hacky but minimal solution that relies on abuse of a hex inverter. Surely a commercially-released product would have a better way of doing things, or at least so I thought!
More recently I noticed someone had uploaded a copy of the application and some accompanying documentation to the Cambridge Z88 page on SourceForge, so I downloaded it to take a look and was very surprised at what I found:
That can't work, surely? The output for recording seems sensible enough, using the 1Ω resistor to ground on the output to reduce the level down to something that could be fed into a sensitive microphone input, but just running the earphone output directly into the RS-232 port's CTS line doesn't seem like it would do the job. There's only one way to find out, though, and that's to build a cable and try it out and to my surprise it does indeed work!
I have had a few issues with this, however. The program appears to require that the phase of the data played back into the Z88 matches the phase that it was recorded. Both of my cassette recorders reverse the phase when playing back the recordings. Fortunately one of them does have a phase reversal switch, and two wrongs in this case does make a right and by setting the phase switch to "reverse" it allows Z-Tape to load back the recorded data.
The overall loader is not particularly reliable, though. It relies on a very strong output from the cassette recorder to successfully register a signal on the Z88's serial port, and I find I have to rewind to try again quite often. That it works at all with such a simple cable is certainly impressive, though.
In my testing I wrote a little BASIC program that crudely checks the signal level on the RS-232 input. You can use this to test the strength of your cassette recorder's output: it will display a rolling progress bar with the approximate signal strength. With my cassette recorder I can get over 80% when playing back a block, but I can't register anywhere near that when connecting the Z88 to my PC's audio output or my phone's headphone socket and consequently can't load back recordings from those devices.
10 *NAME Tape Level Test 20 REPEAT 30 S%=0 40 FORI%=0TO99:S%=S%+(GET(&E5)AND1):NEXT 50 S%=50-ABS(50-S%) 60 PRINT'S%*2;"% ";CHR$1;"R";CHR$1;"3N";CHR$(32+S%);" ";CHR$1;"G";CHR$1;"3N";CHR$(32+(50-S%));" ";CHR$1;"3-RG "; 70 UNTIL INKEY(0)<>-1 80 PRINT
Once I'd experimented with Z-Tape and a cassette recorder I thought it would be interesting to see how it worked and whether I could reverse-engineer the format used. I connected the Z88 to my PC, made some recordings, and then set to work.
Bit-level format
The first thing to do is to establish the base frequency. After taking a recording from the Z88, I checked it in Audacity's frequency analyser and found a strong peak at 1590Hz:
There is also a strong peak at 3195Hz, which is very close to twice the other peak's frequency (halving it gives us 1597.5Hz, close to 1590Hz). Based on these measurements it would seem that the base frequency is around 1600Hz, and likely that the tape format is a combination of 1600Hz and 3200Hz tones. Zooming into the recorded waveform shows the two different tones:
A common way to record data on tape is to use one full cycle of the base frequency to represent a "0" bit and two full cycles of twice the base frequency to represent a "1" bit. This means that the data is the same length regardless of how many "0"s or "1"s appear in the data, and looking at the length of data blocks in the recording they were all the same length, so it seems this is a possible candidate.
The phase of the signal is also important. If we represent the signal as a sine wave, a phase of 0° would start at zero, increase in the positive direction for the first quarter of the wave, head down in the negative direction for the next half of the wave, before returning to zero in a positive direction in the last quarter of the wave. Conversely a phase of 180° would start from zero but go negative in the first half of the wave before going positive in the second half of the wave. The phase can be determined by looking at the start of the signal after a period of silence:
As the signal goes positive first after a period of silence we can confirm the signal has a phase of 0°. In summary, the bit-level format required by Z-Tape is as follows:
- Base frequency of 1600Hz.
- Phase of 0°.
- "0" bits encoded as one full cycle at base frequency (1600Hz).
- "1" bits encoded as two full cycles at twice the base frequency (3200Hz).
Block-level format
Now that we have a stream of bits, we can group them into blocks of data on the tape. Each block starts with a leader or pilot tone, which is effectively a long stream of "1" bits (3200Hz). This lasts 1.25 seconds, after which there is a very brief silence (around two full waves in length) followed by the stream of bits that make up the actual block data.
I created some files on the Z88 that followed certain obvious patterns, for example a file that alternated $00 bytes and $FF bytes so you'd expect to see eight consecutive "0" bits in the recording followed by eight consecutive "1" bits. This would help check to see if there were any start, stop or parity bits in the data (or if it was just eight plain bits of data). I also had a file that contained all of the numbers from $00 to $FF consecutively, so you'd be able to see a clear pattern of byte values counting up and use this to check whether the data was sent least-significant or most-significant bit first.
Using these files I quickly found that the data in each block always starts with two zero bits (immediately after the leader or pilot tone) and is then sent in plain 8-bit bytes (no start, stop or parity bits) with the least significant bit sent first. Each block always contains 1031 bytes of raw data, no matter the size of the file being transmitted. I knew that there was a checksum as Z-Tape would occasionally grumble when loading about a checksum error and I could see that after transferring small files there'd be data at the start of the block, a gap filled with zeroes, followed by a final non-zero data byte. I assumed this was the checksum, and found that by adding up all 1031 bytes in the block the result always came to zero. The checksum can therefore be calculated by setting a counter to zero, subtracting the value of every byte in the 1030 data bytes of the block, and then appending the counter value to as the 1031st byte of the block.
In summary, the block-level format is as follows:
- 1.25 seconds of 3200Hz leader or pilot tone (stream of "1" bits).
- Silence for the duration of two full cycles.
- Two "0" bits, sent as two full cycles of the 1600Hz tone.
- 1030 data bytes, each sent as eight plain bits, least significant bit first.
- "0" bits sent as one full cycle of 1600Hz tone.
- "1" bits sent as two full cycles of 3200Hz tone
- Checksum data byte, sent in same manner as other data bytes, but calculated such that adding up all 1031 data bytes in the block results in 0.
There is approximately half a second of silence between data blocks, though the actual amount of time depends on how much work the Z-Tape application has to do to prepare each block. When building the catalogue before sending a large number of files I've seen gaps over 24 seconds long!
Block contents
Each block always contains 1030 bytes of data plus a checksum byte, and for the sake of simplicity I'll ignore the checksum in the discussion below.
The first byte of each block's data determines what sort of block it is. I've identified six different block types.
The next two bytes are the size of the data included in the block, least significant byte first, though sometimes this value is incorrect or missing depending on the particular type of block.
After that are two bytes that record the block number, least significant byte first. The first block has a block number of 0 and this counts up one for every block on the tape.
After this you'll find the actual data associated with the block, normally up to 1024 bytes, with the rest of the block padded with zeroes.
Blocks $04 and $05: Catalogue blocks
When storing a selection of files on tape Z-Tape writes a catalogue file first containing a list of files. The final block in the catalogue is sent with a block type of $05, if more than one block is required to represent the catalogue then preceding partial catalogue blocks use a type of $04.
Catalogue blocks always have a reported size field of zero.
Each file entry is stored in a record 28 bytes long. As each block can store up to 1025 bytes of user data this allows for up to 36 files to be described in each catalogue block.
The records always start from offset 5 into the block (one byte block ID, two byte size = 0, two byte block number) and each takes the following format:
- Bytes 0~15: Filename.
- Byte 16: 0 (NUL terminator for filename).
- Bytes 17~21: File size as floating-point number (four byte mantissa, MSB first, followed by exponent).
- Bytes 22~24: Three byte time (centiseconds since start of day, LSB first).
- Bytes 25~27: Three byte Julian date (number of days since Monday 23rd November 4713 BC, LSB first).
Filenames can be up to sixteen characters long (12 filename characters, a dot, three extension characters). They can be mixed case.
The file size being a floating-point number took me a while to figure out! This is the numeric format used by BBC BASIC (Z80) and is also internally used by the Z88 OS for its FPP routines. The format for this number can be found in the BBC BASIC documentation, though for the sake of simplicity if you're creating your own catalogue in Z-Tape format note that it does accept the "special case" real number where the exponent is set to 0 and the mantissa is a regular integer. If you're decoding tapes created by Z-Tape you'll need to decode the floating-point number yourself, though.
The date and time are in the format used internally by the Z88 OS. The only challenge here is the Julian day is outside the range that can be represented by some programming language date and time functions which can complicate matters. Here's a snippet of C# that works if you're trying to convert a catalogue date and catalogue time to a .NET DateTime object:
var catalogueDate = DateTime.FromOADate(catalogueDateNum - 2415019); catalogueDate = catalogueDate.AddMilliseconds(catalogueTimeNum * 10);
Blocks $01 and $06: File start blocks
These blocks appear at the start of a file. Block type $06 is used if the whole file data can fit in a single block, $01 if additional blocks containing the rest of the file will follow.
The block size is used here to determine how many bytes of data are present. This will be the size of the whole file if the block type is $06, $03E0 (992 bytes) if the block type is $01.
Block bytes from 5 to 31 contain a copy of the filename, padded with zeroes. This must be in UPPERCASE, regardless of how the file was listed in the catalogue, otherwise the Z-Tape loader will be unable to recognise the file by name (this one took a while to puzzle out!)
After this comes the file data. If this is a block type $06 that's the end of it, but if it's block $01 more file data will follow...
Block $02 and $03: File data blocks
These blocks contain raw file data from offset 5 (there is no filename field, as with blocks $01 and $06) and appear in the middle or end of files. If the block type is $02 then this block appears in the middle of the file and it always contains 1024 bytes of data, though the header will report it contains $03E0 (992 bytes) and should be ignored. If it's block type $03 then that corresponds to the end of the file, and the data length should be taken into consideration.
Block types summary
The following table documents the block types. All multi-byte numeric values are transmitted least significant byte first with the exception of the floating-point numbers representing the file sizes in the catalogue described earlier.
Offset | Catalogue | File | ||||
---|---|---|---|---|---|---|
Partial catalogue block | Final catalogue block | File fits in single block | First file block | Middle file block | Last file block | |
0 | $04 | $05 | $06 | $01 | $02 | $03 |
1~2 | $0000 | Data length | $03E0 | Data length | ||
3~4 | Block number (starting from 0 for the first block) | |||||
5 | Up to 36 28-byte records listing the files about to follow. | The UPPERCASE name of the file, zero-padded to 27 bytes in length. | 1024 bytes of file data. | Data length bytes of file data. | ||
32 | Data length bytes of file data. | |||||
1030 | Checksum calculated so that adding up all 1031 bytes results in 0. |
Creating Z-Tape audio on a PC
This is all well and good, but what's the point of it? The information above may be useful if someone has an old tape that they needed to recover data from but no longer had a Z88, though that seems like a fairly remote possibility. Another possibility could be to create Z-Tape data from files on PC and then play it back to transfer data from the PC to the Z88. Alternatively, a selection of programs could be stored on a CD and loaded onto the Z88 from a portable CD player when out and about.
Maybe not the most useful ideas, but here's a C# function that will take an array of filenames and generate a series of data blocks in the Z-Tape format, including a catalogue:
static byte[][] CreateBlocksFromFiles(string[] files) { List<byte[]> blocks = new List<byte[]>(); // generate the catalogue for (int firstFileInBlock = 0; firstFileInBlock < files.Length; firstFileInBlock += 36) { // which is the last file in the block (+1) that we will write to the file? var lastFileInBlock = Math.Min(files.Length, firstFileInBlock + 36); // catalogue block data is 1030 bytes, same as all other blocks var catalogue = new byte[1030]; // if the is the last block in the catalogue, block type is 0x05, otherwise it's 0x04 catalogue[0] = (byte)((lastFileInBlock == files.Length) ? 0x05 : 0x04); // current block number catalogue[3] = (byte)(blocks.Count >> 0); catalogue[4] = (byte)(blocks.Count >> 8); // write each file for this block to the catalogue var catalogueOffset = 5; for (int fileInBlock = firstFileInBlock; fileInBlock < lastFileInBlock; ++fileInBlock) { var file = new FileInfo(files[fileInBlock]); // file name (can be mixed case) Array.Copy(Encoding.ASCII.GetBytes(file.Name.PadRight(16, '\0')[..16]), 0, catalogue, catalogueOffset, 16); // file size (Z-Tape normally uses floating-point values) catalogue[catalogueOffset + 17] = (byte)(file.Length >> 24); catalogue[catalogueOffset + 18] = (byte)(file.Length >> 16); catalogue[catalogueOffset + 19] = (byte)(file.Length >> 8); catalogue[catalogueOffset + 20] = (byte)(file.Length >> 0); // file date/time var writeTime = file.LastWriteTime; // time is centiseconds since midnight var fileTime = (int)(writeTime.TimeOfDay.TotalMilliseconds / 10); catalogue[catalogueOffset + 22] = (byte)(fileTime >> 0); catalogue[catalogueOffset + 23] = (byte)(fileTime >> 8); catalogue[catalogueOffset + 24] = (byte)(fileTime >> 16); // date is Julian day number var fileDate = (int)(writeTime.ToOADate() + 2415019); catalogue[catalogueOffset + 25] = (byte)(fileDate >> 0); catalogue[catalogueOffset + 26] = (byte)(fileDate >> 8); catalogue[catalogueOffset + 27] = (byte)(fileDate >> 16); catalogueOffset += 28; } blocks.Add(catalogue); } // write each file to the tape foreach (var filePath in files) { var file = new FileInfo(filePath); using (var fileData = file.OpenRead()) { do { var fileBlock = new byte[1030]; // current block number fileBlock[3] = (byte)(blocks.Count >> 0); fileBlock[4] = (byte)(blocks.Count >> 8); // how much data can we store in the block? var maxBlockData = 1024; var blockDataOffset = 5; if (fileData.Position == 0) { // if it's the first block for the file, store the filename (must be UPPERCASE) Array.Copy(Encoding.ASCII.GetBytes(file.Name.ToUpperInvariant().PadRight(16, '\0')[..16]), 0, fileBlock, blockDataOffset, 16); blockDataOffset = 0x20; // can't store as much in the first block due to all the header info we just wrote maxBlockData = 992; // what sort of block is it? if (file.Length > maxBlockData) { fileBlock[0] = 0x01; // first block in a multi-block file } else { fileBlock[0] = 0x06; // single block for the whole file } } else { // what sort of block is it? if (file.Length > fileData.Position + maxBlockData) { fileBlock[0] = 0x02; // continued data block in a multi-block file } else { fileBlock[0] = 0x03; // last data block in a multi-block file } } // how much data can we actually copy? var actualBlockData = Math.Min(maxBlockData, (int)(file.Length - fileData.Position)); // read the data if (fileData.Read(fileBlock, blockDataOffset, actualBlockData) != actualBlockData) { throw new InvalidDataException(); } // store the data size fileBlock[1] = (byte)(actualBlockData >> 0); fileBlock[2] = (byte)(actualBlockData >> 8); blocks.Add(fileBlock); } while (fileData.Position < fileData.Length); } } return blocks.ToArray(); }
Once the blocks have been generated, we can convert them to a tape format like UEF:
static void WriteUef(string filename, IEnumerable<byte[]> blocks, ushort baudRate = 1600, bool reversePhase = false) { using (var uefFile = File.Create(filename)) using (var uefWriter = new BinaryWriter(uefFile)) { // Header uefWriter.Write(Encoding.ASCII.GetBytes("UEF File!\0")); uefWriter.Write((byte)0x0A); // minor version uefWriter.Write((byte)0x00); // major version // Chunk &0113 - change of base frequency uefWriter.Write((ushort)0x0113); uefWriter.Write((uint)4); uefWriter.Write((float)baudRate); // Chunk &0115 - change of phase uefWriter.Write((ushort)0x0115); uefWriter.Write((uint)2); uefWriter.Write((ushort)(reversePhase ? 180 : 0)); // Write each block to the UEF foreach (var block in blocks) { // Calculate the checksum byte checksum = 0; foreach (var b in block) { checksum -= b; } // Chunk &0110 - carrier tone uefWriter.Write((ushort)0x0110); uefWriter.Write((uint)2); uefWriter.Write((ushort)(baudRate * 5 / 4)); // Chunk &0112 - integer gap uefWriter.Write((ushort)0x0112); uefWriter.Write((uint)2); uefWriter.Write((ushort)2); // Chunk &0102 - explicit tape data block uefWriter.Write((ushort)0x0102); uefWriter.Write((uint)2); uefWriter.Write((byte)14); // bit count = (chunk length * 8) - 14 = 2 bits uefWriter.Write((byte)0); // 2 zero bits // Chunk &0102 - explicit tape data block uefWriter.Write((ushort)0x0102); uefWriter.Write((uint)(2 + block.Length)); uefWriter.Write((byte)8); // bit count = (chunk length) * 8 - 8 uefWriter.Write(block); uefWriter.Write(checksum); // Chunk &0112 - integer gap uefWriter.Write((ushort)0x0112); uefWriter.Write((uint)2); uefWriter.Write((ushort)(baudRate / 2)); } } }
A .wav file is probably an easier format to work with, however!
static void WriteWav(string filename, IEnumerable<byte[]> blocks, int baudRate = 1600, bool reversePhase = false, uint sampleRate = 48000, uint channelCount = 1, ushort bitsPerSample = 16) { // generate cycles var cycleSampleCount = sampleRate / baudRate; var bits = new byte[3][]; // good old ternary logic - true, false, and file_not_found. for (int b = 0; b < 3; ++b) { bits[b] = new byte[cycleSampleCount * bitsPerSample / 8 * channelCount]; } for (int c = 0; c < cycleSampleCount * channelCount; ++c) { double a = ((c / channelCount) * Math.PI * 2.0d) / cycleSampleCount; for (int b = 0; b < 3; ++b) { double v = b == 2 ? 0 : Math.Sin(a * (1.0d + b)); if (reversePhase) v = -v; switch (bitsPerSample) { case 8: bits[b][c] = (byte)Math.Round(Math.Max(byte.MinValue, Math.Min(byte.MaxValue, 127.5d + 127.5d * v))); break; case 16: short vs = (short)Math.Round(Math.Max(short.MinValue, Math.Min(short.MaxValue, (short.MaxValue + 0.5d) * v))); bits[b][c * 2 + 0] = (byte)(vs >> 0); bits[b][c * 2 + 1] = (byte)(vs >> 8); break; } } } using (var wavFile = File.Create(filename)) using (var wavWriter = new BinaryWriter(wavFile)) { // RIFF header wavWriter.Write(Encoding.ASCII.GetBytes("RIFF")); // chunk ID var riffDataSizePtr = wavFile.Position; wavWriter.Write((uint)0); // file size (we'll write this later) wavWriter.Write(Encoding.ASCII.GetBytes("WAVE")); // RIFF type ID // chunk 1 (format) wavWriter.Write(Encoding.ASCII.GetBytes("fmt ")); // chunk ID wavWriter.Write((uint)16); // chunk 1 size wavWriter.Write((ushort)1); // format tag wavWriter.Write((ushort)channelCount); // channel count wavWriter.Write((uint)sampleRate); // sample rate wavWriter.Write((uint)(sampleRate * channelCount * bitsPerSample / 8)); // byte rate wavWriter.Write((ushort)(channelCount * bitsPerSample / 8)); // block align wavWriter.Write((ushort)bitsPerSample); // bits per sample // chunk 2 (data) wavWriter.Write(Encoding.ASCII.GetBytes("data")); // chunk ID var waveDataSizePtr = wavFile.Position; wavWriter.Write((uint)0); // wave size (we'll write this later) var waveDataStartPtr = wavFile.Position; // write half a second of silence for (int i = 0; i < baudRate / 2; ++i) { wavWriter.Write(bits[2]); } // Write each block to the WAV foreach (var block in blocks) { // write 1.25 seconds of carrier tone for (int i = 0; i < baudRate * 5 / 4; ++i) { wavWriter.Write(bits[1]); } // write gap wavWriter.Write(bits[2]); wavWriter.Write(bits[2]); // write two 0 bits wavWriter.Write(bits[0]); wavWriter.Write(bits[0]); // calculate the checksum as we go byte checksum = 0; // write all of the bytes in the block for (var i = 0; i < block.Length + 1; ++i) { // fetch the byte to write byte b; if (i < block.Length) { // use data from the block and update the checksum b = block[i]; checksum -= b; } else { // write the checksum b = checksum; } // write each bit, LSB first for (int bit = 0; bit < 8; ++bit) { wavWriter.Write(bits[b & 1]); b >>= 1; } } // write half a second of silence for (int i = 0; i < baudRate / 2; ++i) { wavWriter.Write(bits[2]); } } // update wave size var waveDataEndPtr = wavFile.Position; wavFile.Seek(waveDataSizePtr, SeekOrigin.Begin); wavWriter.Write((uint)(waveDataEndPtr - waveDataStartPtr)); // update RIFF size wavFile.Seek(riffDataSizePtr, SeekOrigin.Begin); wavWriter.Write((uint)(waveDataEndPtr - 8)); } }
But, I hear you say, didn't you earlier mention how a PC's audio output was now powerful enough to drive the Z88's serial port? I did indeed, and that's why I've also put together this little circuit:
This is based on the tape interface circuit I devised for the Sega Master System and uses an SN74LS04N hex inverter chip as an amplifier to drive the Z88's CTS line. It's designed to be powered from the Z88's serial port which provides 5V at 1mA on the DTR pin. This current limit does seem awfully low and I have seen it reported as 10mA in some places but I'm not sure if that's a typo or not — the user manual states 1mA. In my testing this circuit consumes between 2mA-3mA which is much more than 1mA but it does still work, however I would strongly recommend doing your own testing before hooking anything up to your Z88's serial port. The other hex inverter chips I tried all consumed over 20mA in this use which is far too much for the Z88! There was a noticeable difference in current consumption depending on whether unused inputs were tied high or tied low, so please do your own testing.
The presence of a phase switch does allow this circuit to be used with recorders that reverse the phase when recording but don't provide a phase reversal switch of their own to fix this on playback.
All in all I'm very impressed that the Z-Tape software works as well as it does considering the simplicity of the hardware, and it's been a lot of fun digging into how it works.