Building your own analogue sensors for the SmartBox

Friday, 29th November 2024

This is an extremely belated followup to a post from November last year, though one of the reasons for the delay was repeatedly being sent the wrong electronic components which ultimately led to the post from December. I'll try to explain as best I can...

Towards the end of last year I bought myself a SmartBox, a computer control system that's based around a 65C02 CPU and that runs programs written in an interpreted BASIC-like programming language, with software uploaded via a computer's serial connection (and once the programs are loaded onto the box, the serial connection can be severed and the program will continue to run). In the previous journal entry I focussed more on the software side, though to get practical use out of the box I had experimented with some of the hardware too and had built some analogue sensors that I was hoping to write up for the benefit of anyone else who'd acquired a SmartBox but none of the sensors to go with it.

Photo of a SmartBox computer control system with two home-made User Adaptors and two home-made temperature sensors
Two home-made User Adaptors and two home-made temperature sensors.

The four analogue sensor ports on the side of the SmartBox use 5-pin DIN sockets (180° to differentiate them from the 240° 5-pin DIN socket used for the serial port). Each sensor port can measure an analogue value with an 8-bit resolution (between 0 and 255) with the maximum voltage refererence being set to 2.55V – that is, each unit in the 0–255 range reported by the sensor corresponds to 1/100V (10mV), so if you connected a 1.5V source to the sensor port you'd see a value of 150 returned.

Sensors were made that measured specific physical properties (such as light level, humidity, temperature, or sound level) as well as generic "User Adaptor" boxes that allowed users to connect their own sensors:

Photo and description of the User Adaptor from a brochure
The SmartBox "User Adaptor" pictured in a brochure.

One other nice trick that the analogue sensors had was that they could report which type of sensor they were directly to the SmartBox, so when you plugged in a temperature probe (for example) it would know that it was measuring temperature on that sensor port. The way this was implemented is via a "sense signal" output pin from the SmartBox to the sensor. In normal operation it was low, and the voltage read on the analogue sense input pin was used as the sensor's current value. Once a second or so the SmartBox would drive this pin high, and the voltage read on the analogue sense input pin was used to determine the type of sensor.

There are 32 possible sensor IDs. The ADC reading mentioned in the table below is the minimum value that will cause the Smart Move software to identify a particular sensor, for example a humidity sensor can be identified with an ADC reading between 157 and 164:

ADCSmart Move 1.16Smart Move 1.18
0No sensorNo sensor
5mT
13mmTemp
21No sensorNo sensor
29No sensorNo sensor
37TempVolts
45TempTemp
53mAVolts
61SoundTemp
69SoundSound
77SoundPH
85Pulse
93PositionPosition
101
109
117
125LightLight
133
141
149
157HumidityHumidity
165Sound
173Light
181PressureSound
189
197PHAtmos
205Light
213User
221AdaptorAdaptor
229LuxTemp
237VoltsLGate
245Wind
253TempTemp

Note that Smart Move 1.16 and Smart Move 1.18 have some different names for some sensors. If a space is left blank in the table then the sensor is treated as present but is labelled "SENSORA" to "SENSORD" instead of being renamed according to the sensor type.

An analogue multiplexer (such as a 4053 IC) can be used as the basis for an analogue sensor for the SmartBox, sending either the voltage to measure or a reference voltage set with a preset potentiometer to identify the sensor type. The "sense signal" output from the SmartBox chooses which of the two voltages to return:

Schematic for a DIY SmartBox 'User Adaptor'

When the adaptor is connected to the SmartBox the potentiometer RV1 can be adjusted to choose the sensor type. As this is only checked once per second it can be a little fiddly to dial in the specific value you want — temporarily bridging A0 and A1 on the 4053 (so that RV1 is used as both sensor type and input value) and watching the reported sensor value can make life easier (making sure any sensor is disconnected from the input socket, of course).

Photo of the internals of the home-made User Adaptor
The internal components of the home-made User Adaptor.

Whilst this is a useful circuit for building custom sensors, or making a replica of the generic User Adaptor (my DIY effort pictured above) there is a simpler option that can be used for temperature sensors. The original temperature sensor doesn't seem to have very much circuitry of its own, being just a simple probe with a DIN socket on the end of a wire:

Photo and description of the Temperature Sensor from a brochure
The SmartBox "Temperature Sensor" pictured in a brochure.

The temperature sensor provides a reading in Celsius directly in the software, e.g. a temperature of 25°C is reported with a value of 25. This corresponds to a 10mV/°C sensor, and an example of such a sensor is the LM35. One limitation of the LM35 is that without a negative supply voltage pulling down its output it reports a minimum temperature of 2°C, yet the brochure snippet shown above reports a range of 0°C to 100°C. However, reading the manual for the SmartBox it mentions that the Smart Sense temperature sensor measures temperatures between 2°C and 100°C which seems to further indicate that it is indeed something like the LM35.

We could therefore just connect an LM35 to the User Adaptor circuit shown above and that would work, however there is a simpler circuit. If you consult the table of sensor types at the start of this post you'll notice that the temperature sensor appears at the bottom of the table with the highest sensor value. This means that to be identified as a temperature sensor, the sensor must output at least 2.53V when the "sense" pin goes high. A crude way to implement this is to connect a diode from the sense output pin back to the analogue input pin:

Schematic for a DIY SmartBox 'Temperature Sensor'

When the sense pin is low the voltage at "input" will be higher than the voltage at "sense" so the diode will not conduct and not influence the output of the temperature sensor. When the sense pin is high, however, the voltage at "sense" will be higher than the voltage at "input" and so the diode will conduct and raise the voltage at "input" to above 2.53V, indicating that the connected sensor is a temperature sensor.

Photo of the internals of the home-made temperature sensor
The internal components of the home-made temperature sensor.

According to the documentation for the SmartBox the inputs are protected against overvoltage so I believe this is a safe circuit to use, however as with the User Adaptor all circuits here are based on some rough guesses as to how the devices would have originally worked as I don't have any real sensors to test with myself. In any case, these sensors work for me as I've not been able to source any of the original parts, so maybe if you like me have an old SmartBox knocking around these may help you get some more use out of it.

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.

Photo of Sharp PC-1500 Pocket Computer connected to the CE-150 Printer and Cassette Interface
Sharp PC-1500 Pocket Computer connected to the CE-150 Printer and Cassette Interface

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…

Photo of corroded metal plate on CE-150        Photo of corroded metal plate on CE-150

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.

Photo of corroded circuit traces inside the PC-1500        Photo of corroded metal bracket inside the PC-1500

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.

Photo of repaired circuit traces inside the PC-1500        Photo of repaired traces inside the PC-1500

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!

Photo of Sharp PC-1500 Pocket Computer showing the 'NEW0? :CHECK' prompt

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:

Screenshot of Audacity showing the pilot tone of the recorded file

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:

Screenshot of Audacity showing the frequency-shift keyed data in the recorded file

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:

Screenshot of MediaInfo showing the 5kHz sample rate of the recording

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:

Screenshot of Audacity showing the signal being distorted when being resampled.

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:

Screenshot of Audacity showing the pilot signal disappearing when being resampled.

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:

Screenshot of Repeat Samples plugin.

Screenshot of Audacity showing the pilot signal having each sample repeated eight times.

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:

Screenshot of Set Rate dialog showing new 40kHz value.

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:

Screenshot of Audacity showing distortion on the square wave after resampling to 44.1kHz.

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:

Screenshot of Low-Pass Filter plugin.

Screenshot of Audacity showing the low-pass filtered signal.

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:

Screenshot of Normalize plugin.

In summary, the steps are as follows:

  1. Normalise the file (Effect→Normalize)
  2. Repeat all samples by 8× (Effect→Repeat Samples)
  3. Set track sample rate to 8× what it was (right-click, Rate→Other, multiply the value by 8)
  4. Resample to 44.1kHz (Tracks→Resample)
  5. 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.

Photo of various 1-Wire devices around a Z88 computer which has a 1-Wire interface adaptor plugged into its serial port
A selection of 1-Wire devices and a 1-Wire interface adaptor plugged into a Z88 computer

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:

Circuit diagram for the Z88 to 1-Wire interface circuit

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.

Photo of the assembled 1-Wire interface for the Z88

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:

1-Wire protocol timing information diagram from Microchip AN1199

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.

Photo of a TM1990A iButton on a fob with a probe to read it with
An example of a TM1990A iButton fob (middle) along with the probe used to read it (top)

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.

Photo of a circuit board used to provide multiple sockets to connect more than one 1-Wire device at a time
Each socket is wired in parallel to allow multiple 1-Wire devices to be connected to the Z88

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.

Photo of two DS18B20 sensors, one in a TO-92 package and the other in a cabled probe
A DS18B20 temperature sensor in a TO-92 package and another in a cabled probe

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).

Screenshot of the Z88 Alarm popdown being configured to run the task

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:

Chart of the three temperature sensors logged over a 24-hour period

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.

Photo of a Z88 running connected to many 1-wire devices and running the demo program

The files accompanying this post can be downloaded below:

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.

Photo of the top of an Economatics SmartBox

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.

Photo of the main circuit board inside the Economatics SmartBox        Close-up photo of the SmartBox CPU

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.

Screenshot of SmartMove downloading progress dialog
Screenshot of SmartMove running a Hello World PRINT statement
Screenshot of SmartMove editing a procedure to collect data from a sensor and log it to disk

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:

Screenshot of SmartMove .NET application editing a procedure named 'step'

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).

Screenshot of SmartMove RISC OS application editing a procedure named 'step'

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!

Screenshot of Logicator RISC OS application editing a flowchart

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:

Screenshot of SmartFlow RISC OS application downloading to 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:

Screenshot of SmartFlow RISC OS application running a flowchart on the SmartBox

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!

Video thumbnail for demonstration of modified Arculator on YouTube

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.

'The BBC Master Series Microcomputer' Welcome tape title screen        'Ebony Castle' BBC Micro game

'Spooks'n'Spirits' BBC Micro game        'Techno Zone' joke advert from Digitiser

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.

Memory layout of banked CP/M

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:

Simple bank-switching hardware using a 4-input AND gate and a 2-input OR gate

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:

Simple bank-switching hardware using a four 2-input NAND gates

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.

Photo of a NAND gate chip installed in the computer

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.

Photo of the computer's boot screen showing a 60KB TPA

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!

Page 1 of 53 1 2 3 4 553

Older posts RSSSearchBrowse by dateIndexTags