Digital Control - Software
The brains of the system, and the only 'programmable' part of the hardware, is that 8-bit PIC microchip. All other hardware is pre-made and designed to receive commands, and/or send commands.
When I first started the implementation, at the time the hobbyist would typically code in PIC assembler language. Assembler is a low-level programming language that closely resembles the target machine code.
But programming with assembler is quite hard, especially for beginners and since I was going to use my first build for my final year university project, I was very worried about using assembler to do the job. It's one thing to blink a few LEDs and turn a stepper motor in computer labs using assembler - it's quite another to control several relays, switches, volume chips, a graphic LCD display (that I used at the time) and support RS232 communications.
Having learned Java, and a bit of C in the previous years at University, after some searching around, I found a good value C to assembler compiler named C2C, by Source Boost. C2C was a good start and I managed to get up and running with it quite well. The IDE is simple, but fast and not overwhelming.
C2C was later changed to BoostC, and that is what I use now and what all the code below is based on. I think it's a great implementation, easy to program and reads well. You can also try it for free for non-commercial projects which use less 4k ROM for PIC18 (2k for PIC16), which gives a great introduction. My complete code comes in at just over 5k, so won't work in the free compiler, but could come less if you dropped features (e.g. Bluetooth / RS232 comms).
I now find programming PICs in BoostC one of the most interesting and rewarding coding activities I do, and a very unique skill that I've not yet seen any other developer in my day job have. But it's not that hard, even though you do need some patience, as you don't know whether your code really works or what bugs arise until you burn it into the PIC and switch on your creation. There's no simulator to easily setup with all the external hardware needed.
To get that compiled code onto the PIC, you will need some extra hardware too - namely a PIC programmer.
I originally used a serial port programmer - the PIC JDM programmer. This needs a PC with the legacy RS232 9-pin serial port though, which is very rare these days. I do have a desktop PC with one still, but the last four laptops I've owned never had one. USB to RS232 adapters sadly do not work with it as they do not provide enough voltage.
To improve convenience, I built the UsbPicProg programmer. Their website and code updates are a bit inactive these days, but the programmer works very well, and you can build it yourself as I did. To build it, I etched my own dual layer board and using that JDM programmer one last time to burn provided hex file to the PIC18F2550. PCB layouts are even provided. Once you've got the firmware on the PIC18F2550 once, firmware upgrades can be programmed via USB, so that JDM programmer has been unused for years.
The UsbPicProg software works well, and with a bit of fiddling, even works well in Windows 10. You open up the hex file generated by the BoostC compiler/linker in the UsbPicProg interface, and program the target PIC via the UsbPicProg and an ICP cable. It programs and verifies the code pretty quickly.
You can download my entire C code here. Should really cut your work down if you're basically building the exact same thing, but as a hobbyist, DO take it, look at it, learn it, fiddle with it and if you improve it, let me know!
Below I've described in a more detail how each section works with snippets which should interest the programmer inside you.
Initialisation and Configuration
Before a PIC can run code that you've written, it needs some configuration (done during programming), and initialisation (run in the code).
Configuration involves telling the PIC what speed to run at, how to run at that speed (what oscillator), what protection is in use (watchdog, code protection etc) and so on.
Once a PIC powers up initially or after a reset - you need to tell it what pins are inputs, what are outputs, what speed the timers tick at, what speed to run serial ports at, what interrupts are active and so on. These kinds of settings are done in an init routine, because they are something you need to set, but these settings can be changed at runtime later (so are not set during programming).
The PIC18F4455 datasheet has details of what the configuration registers set. This varies per PIC so check the specific datasheet.
BoostC can have a set of #pragma variables in the source code, which make it through to the hex file and UsbPicProg detects these and writes the result to the PIC during programming.
My configuration above is basically setting the bits in each configuration register appropriately. Many of these are defaults, however the keys ones are:
|Register||Bit Names||Bits||Use||My setting|
|CONFIG1L||CPUDIV1:CPUDIV0||43||System Clock Postscaler Selection||01 - the primary oscillator divided by 2 to derive system clock. As I use a 20MHz|
|CONFIG1H||FOSC3:FOSC0||3-0||Oscillator Selection||1101 - HS oscillator (High Speed crystal, with no PLL). This is the correct setting for an external crystal above 4MHz.|
|CONFIG2L||!PWRTEN||0||Power-up Timer Enable||1 - which means it's disabled|
|CONFIG2L||BOREN1:BOREN0||2-1||Brown-out Reset Enable||11 - which means Brown-out Reset is enabled in hardware only|
|CONFIG2L||BORV1:BORV0||4-3||Brown-out Reset Voltage||11 - which means the minimum setting|
|CONFIG2L||VREGEN||5||USB Internal Voltage Regulator Enable||0 - which means it's disabled (don't need it as not using USB)|
|CONFIG2H||WDTEN||0||Watchdog Timer Enable||0 - which means it's disabled. Enabling the watchdog resets the PIC if code is unresponsive, but prevents long delays from running.|
|CONFIG3H||PBADEN||1||PORTB A/D Enable||0 - which means portb is digital on reset (not analogue)|
|CONFIG3H||MCLRE||7||MCLR Pin Enable||1 - which means it's used to reset the PIC instead of an I/O pin|
|CONFIG4L||STVREN||0||Stack full/underflow reset enable||0 - don't reset if this happens|
|CONFIG4L||LVP||2||Single-Supply ICSP Enable||0 - don't support low voltage programming|
|CONFIG4L||ICPRT||5||In-Circuit Debug/Programming Enable||0 - disabled|
|CONFIG4L||XINST||6||Extended Instruction Set Enable||0 - legacy mode|
|CONFIG4L||DEBUG||7||Background Debugger Enable||1 - Background debugger off. Allows RB6 and RB7 to be used.|
|CONFIG5L||CP3:CP0||3-0||Code protection||1111 - no read protection|
|CONFIG5H||CPD:CPB||7-6||Code protection||11 - no read protection on EEPROM or boot block|
|CONFIG6L||WRT3:WRT0||3-0||Write protection||1111 - no write protection|
|CONFIG6H||WRTD, WRTB, WRTC||7-5||Write protection bits||111 - no write protection on EEPROM, boot block or config register|
|CONFIG7L||EBTR3:EBTR0||3-0||Table Read Protection||1111 - no read protection|
|CONFIG7H||EBTRB||6||Boot Block Table Read Protection||1 - no read protection|
The final #pragma tells BoostC what speed the system clock is, so that it gets the delay routines accurate.
Initialisation is called from the main() method (the entry point to running most programs). It is called before the endless loop starts in the main() method.
Its purpose is to set the configuration of everything we need, ports etc. Below is my initialise() method:
Quite a lot is done there - so let's break it down:
- Ports - the TRIS and PORT registers are set. TRIS registers tell a pin whether it is an input or output. For example, portb has three input pins - RB0, RB1 and RB2 so it is set to 0x07 (in hex) or 00000111 in binary. The PORT register can be written to for pins set as output pins, or read from for pins set as inputs. At initialisation, I set most outputs to off.
- OSCCON - here I set what happens when sleep() is called. It was experimentation only and unused.
- ADCON - here I setup the ADC (analogue to digital) feature. I don't use it at all, so ADC is off and all ADC pins are set to allow digital I/O only
- UCON - USB is off
- Timer 1 - Here I setup the Timer 1, see below
- Timer 2 - Here I setup the Timer 2, see below
- RB0 - RB0 is an input connected to the loss of AC detector. Using INTCON and INTCON2 we can tell the PIC to raise an interrupt if this input goes high
- RB1 - RB1 is an input connected to the IR sensor. Using INTCON2 and INTCON3 we can tell the PIC to raise an interrupt if this input goes high. During the IR routine, we also change it later to interrupt on going low, but the initial detection is going high.
- RB2 - RB2 is an input connected to the speaker DC detector. Using INTCON2 and INTCON3 we can tell the PIC to raise an interrupt if this input goes low (i.e. an amp failure occurred and DC was detected)
- INTCON RBPU - Disable the built in Port B weak pull-ups (otherwise DC fail is not detected)
- INTCON PEIE - Enable all the peripheral interrupts (required for RS232)
- cTask - This is used for my task scheduler in the main() loop. It's set to zero for no task to run
- RS232 - SPBRG, TXSTA, RCSTA, BAUDCON and PIE1 deal with the setup of RS232 communications. This includes setting the baud rate to 9600, and setting the communication options. I used a common configuration of 8 bit communication, asynchronous, no check or frame error bits. The datasheet has all the options, including calculations for the baud rate, in my case ((10000000 / 9600) / 16) - 1 with 10000000 being 10MHz, 9600 being the baud rate. Finally, PIE1 sets the interrupt on receive option.
- I delay a few seconds before setting up the external hardware, just to allow the power supply to stabilise more.
- ledSetup(), ledOn(), ledTest() - options for the MAXL7219 drivers, and a test
- bluetoothSetup() - options for the bluetooth module (AT commands). Causes a delay.
- ledClear(), ledOff() - switch off the MAX7219 display
- enableInterrupts() - enables all interrupts. System is now ready to use.
Remember, this initialisation routine only runs when you first plug the mains cord into the unit. When the amps are powered off, the PIC is still running 24x7. If you intend to not leave the PIC on standby, the delays in the initialisation could probably be shortened a lot, but I did not experiment.
Before we move on to the main code and routines, I'll explain a few concepts with PIC (and most microcontroller) features that are used here.
Five timers are built in to the PIC18F4455, Watchdog, Timer0, Timer1, Timer2 and Timer3, and I use 2 of them. They run independently of the code, and when running, can cause interrupts when they count fully (the counter register overflows).
Timer 1 I use to count 5 seconds after an event has occurred. It's only now used to check if a DC fault has cleared. In the past it was also useful when I used an LCD display, allowing it to reset back to the standard information display and turn off the backlight after an event.
In the initialisation, I set the timer to a high interval of 210ms (milliseconds). You can't get all the way to 5 seconds on the timer interrupt alone so when the timer does interrupt, that counts another variable to 24 (2000ms / 210ms). It doesn't need to be completely accurate as it's not driving a clock or interface, 5.04 seconds is fine and humans won't notice!
Timer 2 is used for timing pulses on the RC5 infrared communications. This allows the IR commands to be received via interrupts on both this Timer 2 ticking at just shy of 890 μs (microseconds) and the IR receiver changing state. The 890 μs timer needs to be more accurate, though there is a little leeway.
A great resource for calculating the setup values for a timer is here - http://eng-serve.com/pic/pic_timer.html. By specifying your clock speed, it gives the values needed to setup the timer to interrupt at a period needed.
The code only turns on the timers when they are needed - i.e. an event occurred or IR state changed. This is for efficiency, and would allow the PIC to sleep too (though I didn't implement that in the end).
There are two ways of detecting events - polling for them in your code by constantly checking an input, or letting the PIC do the checking for you and firing an interrupt.
Polling is often done, but the PIC and C compiler I'm using is not multi-threaded, so you cannot have one thread doing the poll constantly, whilst another thread does other work.
The alternative is an interrupt routine. This routine basically allows us to say 'an event has happened, stop whatever you are doing immediately and handle it'.
On something critical - such as the DC failure, or the loss of AC to immediately write data to EEPROM, we want these events to be picked up and actioned immediately - a perfect example of what an interrupt routine can be used for.
I'm also using the interrupt routine for other tasks too, these being when timers overflow, IR sensor received a signal, RS232 received data. This is because it is convenient for the PIC to detect these events and immediately act on them in the Interrupt routine. Interrupts can also wake the PIC up from sleep, if you decide to sleep whenever there is no activity (saving power).
Be warned though - when an Interrupt occurs, another one cannot occur until the first is processed. This means code in the interrupt routine needs to be short, simple and swift. Long running commands, such as time delays, writing output to LED displays, serial comms, rs232 comms etc. are best avoided directly in the interrupt routine.
The solution to this is to put a simple task scheduler in the main loop (details later) so that the interrupt routine can flag 'there's something to do', then exit and leave the main program to schedule it and run it.
The main() method
The main() method is where the code starts running, and mine is pretty simple:
- Run the initialise method
- Loop infinitely
Inside the infinite loop, there is a task scheduler which checks the cTask variable for any bits that are set in that 8-bit character. This scheduler will allow up to 8 different tasks, and I use 5.
The if ... else statement then prioritises those tasks in order of importance. This is so if two interrupts happened at pretty much the same time, the most important one would execute first. This priority is:
- DC failure - in the scenario of a faulty amplifier. The muting of the speaker relay is done in the interrupt routine itself because it needs to happen immediately, but the next level of priority is to display to the user that a fault has occurred, and then start the timer (5 second timer1) which will check and clear the fault if it is gone
- Loss of AC - in the scenario that the power is lost to the whole unit, the next level of priority is to save the data to EEPROM, before the PSU capacitors empty and our PIC shuts down. EEPROM data should not be written directly in the interrupt routine itself, since writing to EEPROM can generate interrupts itself.
- Timer 1 finished counting (actually finished counting 24 times to reach 5 seconds) - this is to reset the display / re-check DC fault
- IR signal received - this is to handle a user pressing buttons on the remote control, and process that command
- RS232 data received - this is to handle a user sending a command via the Bluetooth connection
I've also commented out an example of sleeping, but did not use it in the final solution as the time to wake from sleep can be intrusive to processing commands (such as those received by the IR remote).
Now I shall move on to describing how each event is handled in some more detail.
Output communication - LEDs, speaker mute relays and power relay (soft-start)
The communication here is the most simple - on or off, or basically set a pin high, or set it low. In my .h (header) file, I've named all pins for convenience and easy adjustment (note, input pins also listed).
To set a pin, e.g. the colour Red on the Tri-colour LED:
To turn it off again:
And that's it! RED maps to pin 3 on PORTA as defined in my header. You could also put porta.3 = 1 (or 0) too. Throughout, you will see many examples of me setting and clearing outputs.
Output communication - controlling the audio relays 74HCT595
The five audio relays and two 12V trigger outputs are controlled by the PIC. These switch between 4 different stereo inputs, between internal and external 5.1 decoding, and outputs to drive external equipment via a 12V trigger.
Rather than consume 7 pins on the PIC microprocessor itself, the 12V outputs (relays and triggers) are controlled by a serial communication, needing only 3 pins.
A 74HCT595 shift register logic chip is installed on the preamp board, which receives a single byte (8 bits) by clocking in each individual bit (true/false) value, one at a time. Once all bits are received, the 74HCT595 will then put that byte on its parallel output pins (all 8 of them). A ULN2003A allows those low current 5V pins (7 of them at least) to drive higher current, 12V outputs.
The PIC writes the byte to the shift register via a serial communication. It does this in software by a process named as 'bit-banging'.
This method (writeRelay) is basically doing two things. First it calculates a single byte which contains the true/false values for each relay and trigger, and secondly, it uses a for loop which tests each bit in that byte to put on the serial data line, and pulses the serial clock high and low so the 74HCT595 reads it in.
Why not use hardware? The PIC does have a SPI hardware module which means you don't have to do bit-banging like I have, but it's not in a convenient place as the SPI clock pin is RB1 - meaning I couldn't use that for external interrupt, and the SPI data pin is on RC7, meaning I couldn't use the RS232 receive. Bit banging a single byte, or even 6 bytes is quick enough! Other PICs may use different pins though, and you could choose to use SPI hardware instead.
The code comments above details how the method above works, so you can understand a bit more detail what line of code does what.
Output communication - controlling the volume level PGA2310
This is similar to the relay method, but the difference is it is writing 6 bytes instead of 1. That's 1 unsigned byte (representing 0 to 255) representing the volume level per channel.
The PGA2310s are daisy chained, and once a chip has received two bytes and starts receiving more (which the latch is still low), the first bytes it has received start get clocked out to the next chip in the chain.
For my setup, the PGA2310 controlling the volume level for the subwoofer and centre channels is the last in the chain, so the PIC must send the volume levels for these channels first. The levels for rear right and left follow, and front right and left channels is the last two bytes to be sent.
Like the relay method, this one (writeVolumes) is basically doing two things. First it calculates a volume level byte per channel, by adjusting the balance and rear/centre/sub level adjustments to the overall volume to which are stored in an array. Secondly, it uses a for loop which tests each bit in that byte in a position in that array to put on the serial data line, and pulses the serial clock low and high so the PGA2310 reads it.
The loop is longer - 48 iterations because of 6 bytes to write (8 bits * 6), but the operations that occur in the loop are simple and work very well. Again, code comments detail the specific lines.
Output communication - displaying on the LED display MAX7219
The MAX7219 is my third serial device, and again the bytes are sent to it in a very similar method.
The MAX7219 expects two bytes. The first is the 'command' which will be a hex value mentioned in the datasheet, or a number from 1 to 8 to say which digit to write to. The second byte is a 'value', which is either a value relevant for that command (usually 1 or 0), or a hex value to specify which segments to set active on that digit.
It's a very convenient chip to use, and can be daisy chained too. I use two modules, so this allows me to output content to 16 digits with just 3 data pins on the PIC.
Without the MAX7219, in an extreme case of driving every segment of every display directly - I'd need 128 pins! That's 7 segments plus the dot multiplied by 16. Traditional BCD to 7-segment decoders would half that only, and prevent me displaying letters anyway. Multiplexing (and Charlieplexing) reduces pin usage further (as well as power consumption), but would need a dedicated chip anyway. MAX7219 does it all for us.
So, to write to the display and display numbers or (some) letters on those 16x 7-segment displays, I write 32 bytes in total. This is done in eight separate serial writes consisting of four bytes each - that's two bytes (command and value) for the second module in the daisy chain, and two bytes for the first.
Configuration - before I use the MAX7219, a number of configuration commands need to be written in order for it to display what I need. These are:
- Scan limit - 0x0B 0x07 : Scan all 8 digits in the display
- Decode mode - 0x09 0x00 : Do not decode - we could tell it to decode the bytes sent as BCD and let it display the numbers on the 7-segment displays, but that would prevent displaying letters
- Intensity - 0x0A 0x08 : Set the intensity to 21/32. This uses built in PWM so the display is not at completely full brightness, but bright enough to be clearly visible through my tinted plastic front panel
Additionally, I also use the command 0x0C followed by 1 or 0 to start-up or shutdown the display.
As for displaying the data on the segments, because I wanted to display letters as well as numbers on the display, I had to control the individual segments directly.
Fortunately, after some searching around, someone has already done great work already to produce an ASCII to 7-segment conversion table, which I found here.
I then adjust in software to get the final value. This is done by taking of 0x20 because the ASCII array starts at 0x20 (every character in ASCII before that is not printable) and then shifting the bits for each value one to the right - so the representation of ASCII 'A' is 0xEE for the MAX7219 is 0x77, or 0xF7 if we want the dot to display too. The ledChar method does this.
The LED character code I use is below:
For reference, here is the ASCII to 7-segment array, to which I adjusted slightly to display the uppercase T differently:
Examples of displaying my input names on the 7-segment displays. I preferred the alternative uppercase T for TELE instead of tELE. There's no clear way to display the letter V though! PC, Phono and Alt (instead of Aux!) works well though.
The complete sequence to displaying data is:
- ledPrint() is called, with parameters 1: Which line to display on, 2: What to display and 3: Write it to the display now or not
- ledPrint() then calculates the bytes to write out to the MAX7219 for displaying, and writes them to an array, with help of the displayASCIItoSeg array
- If the write parameter is true, ledWrite() is called
- ledWrite() then loops through the 8 bytes in the two arrays (one per line), and then bit-bangs those bytes in the array to the relevant 7-segment digit. 32 bytes written in total, with the output loaded 8 times.
Alternative ledChar() and ledCharHex() (latter is unused) can display and update a single character, leaving the rest intact.
Input events - checking for IR commands
For IR commands, even in the original build of the preamp in 2005, I choose to use the Philips RC5 protocol because it is one of the simplest, well documented, and old enough that modern equipment doesn't really use it (so no conflicts)!
My original RC5 code would look for the first IR command received, and then delay for each pulse, checking for the status. In this rebuild though, I wanted to use an interrupt driven method, so I'm not blocking any other code, and I could also potentially allow the PIC to sleep.
I found this code at ArduinoTamil and decided to use it in my solution instead.
The code is quite clever, because it is using the interrupt routine to handle the logic high and low states of the IR receiver (toggling the edge detection in each interval), and also the interrupt routine for the timer ticks.
By counting an interval of both the timer, and the logic changes, and ensuring that they are the same, it can detect a valid command. The interval can also be used to determine where in the protocol it is currently processing (start bits, control bit, address and command).
Below is my implementation of it - sitting in the interrupt routine:
If the command received is valid, my task scheduler is used to process that command. The interrupt sets the bit TASK_INT_EXT1 in the cTask byte, and the main() method will pick that up and run the rc5Process() method.
This method checks the address. If that's not zero, then nothing more happens and the method exits. Otherwise, the command received via IR is processed instead.
The following commands are processed if the power is on only:
- Command 13 / 0x0D: Mute - do the mute or unmute process
- Command 16 / 0x10: Volume up - increment the overall volume, then write it to the PGA2310 chips and display the adjusted level
- Command 17 / 0x11: Volume down - decrement the overall volume, then write it to the PGA2310 chips and display the adjusted level
- Command 32 / 0x20: Input right (channel up) - increment the selected input, then write it to the 74HCT595, activating the appropriate relays
- Command 33 / 0x21: Input left (channel down) - decrement the selected input, then write it to the 74HCT595, activating the appropriate relays
Command 12 / 0x0C for Power is always allowed. When this command is received, the power on and off sequence is executed.
Certain commands are also prevented from repeating if the button on the remote is pressed and held down. These are Power, Mute and changing the Input up/down - we don't want these cycling states rapidly. Volume however increases or decreases continuously whilst the user holds the button down - we don't want the user to have to rapidly mash the volume buttons to adjust the volume quickly!
RC5 protocol provide a flick or toggle bit for this, and by comparing the previous value to the newly received value, if it remains the same, we know the button is being held down.
Output communication - writing out to Bluetooth RS232 transceiver
The last output communication is to the Bluetooth module. This happens whenever a change is made. Changes are only ever made when someone changes the volume, input, power status etc on the IR remote, or via Bluetooth.
When a change is made, the variables are sent out to the Bluetooth module via RS232 USART. All these variables are sent every time, since only 31 bytes are needed to send the full status. These are:
- Bytes 1 to 2 : P followed by power status (1 or 0)
- Bytes 2 to 5 : V followed by the volume level (0 to 255), split into two bytes
- Bytes 6 to 7 : Q followed by the mute status (0 or 1)
- Bytes 8 to 9 : I followed by the currently selected input (0 to 5)
- Bytes 10 to 12 : F followed by the front balance level (0 to 255), split into two bytes
- Bytes 13 to 15 : R followed by the rear balance level (0 to 255), split into two bytes
- Bytes 16 to 18 : r followed by the rear adjust level (0 to 255), split into two bytes
- Bytes 19 to 21 : C followed by the centre adjust level (0 to 255), split into two bytes
- Bytes 22 to 24 : S followed by the subwoofer adjust level (0 to 255), split into two bytes
- Bytes 25 to 26 : M followed by surround mode (1 or 0)
- Bytes 27 to 28 : E followed by external surround mode (1 or 0)
- Bytes 29 to 30 : T followed by trigger status (0, 1, 2 or 3)
- Linefeed to end communication
For sending a byte that can range from 0 to 255 (such as the volume level) - this can be a problem because the ASCII representation of that includes non-printable characters such as tabs etc. It could also be 10 - which would send a line break too early before the rest of the bits are sent.
To get around this, I send the full bytes in two halves (aka as nibbles). This is the first 4 bits, and last 4 bits of the 8-bit byte sent separately, with decimal 48 added to both of them so that they reach the printable range.
A 4-bit decimal can go from 0 to 15, therefore the ascii representation once 48 is added is 48 to 63. This will be the characters 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, :, ;, < , =, > or ?. The method rs232SendNibble does this split and then sends each half to rs232SendByte.
The method rs232SendByte uses a simple blocking method to send bytes out via the PICs RS232 module. Sending a single byte involves putting that byte into the TXREG register, then wait for the TXIF bit to be set, indicating the byte in TXREG transfers to TSR, and finally wait for TRMT bit, which indicates when TSR is empty.
The rs232Print method is for sending a string (of bytes) via rs232, which will call rs232SendByte until the string is empty.
Input events - receiving data from Bluetooth RS232 transceiver
Receiving data is a bit different from sending. The differences are that the receiving of data is done via interrupts, and the data received will be for a specific command, rather than sending all the configuration at once.
The first piece of code that handles receiving RS232 commands is the interrupt routine. The PIC will throw an interrupt when data is received via RS232 and the RCIF bit in PIR1 gets set (meaning RCREG has a byte received). The if statement checks RCIE in PIE1 to ensure the receive interrupt is enabled.
On the data received, here we check for and handle overruns (OERR bit) by disabling and re-enabling the continuous receiver again. We also check for framing errors (FERR bit) and just read RCREG to clear that.
When bytes are received normally, they are added to the rs232Buffer - which is an array containing all the bytes received. Bytes keep getting added to this array on every interrupt. The index iRS232Index gets incremented each time so the next byte goes into right position.
A wait for TXIF is then executed, to ensure any bytes are not being sent back out, and then we check the last received byte to see if it was a ASCII 10 or 4 (line break or EOT / End of transmission). If either of these are received, TASK_RS232 is flagged in the cTask byte, allowing the scheduler in the main() loop to pick it up.
The rs232CommandReceived() is then called from the task scheduler and this processes the bytes received.
This method is basically looping through the buffer of received bytes. This buffer could be any length, but it will look for the 'command' bytes (either G, P, V, Q, I, F, R, r, C, S, M, E or T) and then assume that there is a byte or two following those indicating the data to be changed (except in the case of G).
G is a special one-byte command that I use for the client (the Bluetooth app) to ask for the current data. The method sendRS232Status() described above is called to do this and sends all variables.
P, Q, I, M, E and T expect to be followed by one byte (ASCII encoded) to indicate the status. We take 48 from the received value to convert it to the real value that needs to go into the PICs variables.
V, F, R, r, C and S expect to be followed by two bytes (ASCII encoded). These two bytes are half-bytes (nibbles) and converted to a single byte which is allowed to have the full range of 0 to 255 by the rs232ReceiveNibbles() method. This method basically takes the two ASCII encoded bytes, takes 48 from both of them and builds the complete byte by concatenating them together (the upper bits cu are created by shifting the bits received 4 to the right, then the lower 4 bits are added).
Once the loop has been through the whole buffer and processed all the command bytes that were received, the iRS232Index is reset. Then the power cycle is run if the power status has changed. If the power is on (and the command wasn't G), then the possible changed volume, input or trigger variables are written out to the PGA2310 chips and 74HCT595 shift register chip and the new status is displayed on the MAX7219 display.
Input events - DC fault occurred
A DC fault is a critical problem, fired if the amplify is outputting a DC signal to the speakers (which would cause the speakers to burn out).
The immediate processing of the event is done in the interrupt routine itself - that being 'disconnect the speakers immediately'. This is done by outputting the off state to the speaker relays.
The task TASK_INT_EXT2 is then flagged, which will be picked up by the scheduler. This runs the showFault() method and switches on the 5 second timer 1.
Once the timer1 count has finished, the DC fault is re-evaluated and if it is gone, operation goes back to normal.
Input events - loss of AC occurred and EEPROM writing
Loss of AC means someone unplugged the mains or something else caused the power to be lost. When this happens, we want to save the current state so that once the amp is switched back on, it returns to the volume level, selected input etc that it previously has. This includes adjustments to rear/centre speaker levels etc - all written to EEPROM.
The loss of AC is triggered by an interrupt (RB0 going high). It is handled by immediately muting the speaker relays so power is saved and no loss of power noises/thumps are heard from the amplifiers. It then flags TASK_INT_EXT0 for the task scheduler.
The task TASK_INT_EXT0 is picked up by the main() loop. This starts the EEPROM writing by calling the saveData() method. Writing to the EEPROM must be done here as it does not work if called directly within the interrupt routine (since it generates interrupts itself).
The saveData() method then calls eepromWrite and puts all the variables that we want to save into the EEPROM at specific locations
To save data to the EEPROM, a specific set of instructions need to occur. This includes loading the data we want to save into EEDATA and the address we want to save to into EEADR.
The relevant bits are cleared/set in EECON1 to allow the save to occur, and then bits are set in a specific sequence into EECON2, followed by the WR bit in EECON1.
The write starts, and we poll EEIF in PIR2 to wait for it to complete, followed by resetting EEIF again. This is why the EEPROM write cannot occur in the interrupt routine itself. During the EEPROM process, we also store the current state of INTCON and restore it after.
Writing to the EEPROM is a few instructions, but it is quick. With my 4,700 μF and 2,200 μF capacitors on the 5V PSU, this holds the power for more than enough time after the mains is lost to complete writing all variables to the EEPROM reliably every time it's needed.
Internal methods - power on/off sequence
The doPower() method is called when the power status changes (either by remote control received, or Bluetooth request). This is a critical process because it is triggering the relays in the soft start, turning on that big 500VA transformer and powering up the amps.
If it not a process that should be immediate. During power on - we wait a while until the amplifiers are stable before unmuting the speakers. During power off, we immediately mute the speakers, then power off the amplifiers, and then wait a while so that the power supply drains. It also prevents rapid power cycling, which could damage the amplifier.
In addition to the delays for the amplifier, this routine does additional work. During power on:
- Powers on the amplifiers (POWEROUT pin switches on the soft-start circuit)
- Make the RGB LED GREEN
- Writes the current volume level to the preamp - this is because the preamp board power is also switched with the amplifier power, so previous state inside the PGA2310 chips was lost
- Enable the MAX7219 display and print HELLO
- Set the iPower variable
- Write the input relay / trigger to the shift register - this is because we switch them all off when the amplifier is off to save standby power
- Flash the RGB LED GREEN and BLUE at 100ms intervals. This is done 27 times to delay 5.4 seconds in total.
- Once that delay is complete, replace the hello message with the current volume and selected input on the MAX7219 display
- Unmute the amplifier - but only if there is no DC failure present
During power off:
- Mute the amplifier
- Make the RGB LED GREEN
- Print a Goodbye message on the MAX7219 display
- Clear the iPower variable
- Write the input relay / trigger to the shift register - the writeRelay() method will detect the power is off, and de-activate all relays and triggers
- Powers on the amplifiers (POWEROUT pin switches on the soft-start circuit)
- Delay one second, then power off the amplifiers (disables the soft start circuit)
- Delay six more seconds, then power off the LED display and make the RGB LED RED (indicating standby)
Internal methods - displaying the volume and active input
I've already covered above how characters and strings are written to the MAX7219 LED display, but not covered what is written. These two methods do that.
The volume one is the most complex. This is because it will convert the volume level (0 to 255) and display the volume attenuation (or gain) in the decibel range that the PGA2310 chip uses. This is −95.5 dB to 31.5 dB in 0.5 dB steps.
If the amplifier is in mute mode, then rather than display -95.5 dB, I explicitly print 'Snd OFF' to the display (for want of a better phrase, but limitations to letters printable to 7-segment displays prevent certain words, like Mute).
Otherwise, the method works out the gain by considering 192 as the 0 dB point. If we do this, for positive gain, taking the volume level from 255, dividing it by 2, and then taking that from 31 gives us a number from 31 to 0. By dividing the volume taken from 254 and taking the remainder (modulus), that tells us whether a decimal point is needed.
For negative gain (attenuation), the volume level is taken from 254, that result divided by 2, and finally -31 taken from that. Whether the decimal is needed or not is the same, but straight away a minus symbol is printed on the display, at position 1.
The ledChar() method doesn't actually sending the commands to the display, it just decodes the ASCII value to the write byte for the LED display and stores it in an array to be written to the MAX7219 on-demand by ledWrite().
If the gain is in single digits, the second position in the LED array is just specified as 0. This means print nothing (no segments active). Otherwise, the second position needs to be the 'tens' portion of our gain number, so we take the modulus (remainder of dividing by) 100, then divide by 10. 48 is added to the final result to get the ASCII equivalent.
Position three on the display is the 'units' part of that number, which is the modulus of 10, plus 48 to get to ASCII. The final argument of ledChar is 1 to indicate that a decimal place will be printed too.
Position four is the decimal part - either 0 or 5, again with 48 added to get to ASCII. Position five is just a space. Positions six and seven is written with the letters d and b (that's lowercase b, technically incorrect, but uppercase b is the same as the number 8 on the display, so looks worse).
Finally, the ledWrite() is called to send the decoded bytes to the MAX7219.
The show input method is much simpler, and just prints strings to the first line of the LED display using a switch statement on the active input. This (in my preference) prints PC, TELE, Phono or ALt. You can change this, but since it always displays, pick a phrase that reads well enough on 7-segment displays, and remember it's limited to maximum 8 characters long.
Internal methods - Bluetooth AT commands
Our final method is just used on initialisation, but after RS232 communication registers have been setup and interrupts enabled. It simply sends serial transmissions to the Bluetooth module.
RS232 Bluetooth modules typically accept 'AT' commands, but check the datasheet. The JY-MCU HC-06 module I use accepts them by first printing 'AT', waiting for a couple of seconds, then sending each AT command.
For me - I just want to set the 'name' of the Bluetooth module so it is something I recognise. 'Amp' is what I set it to, so on your phone if you search for Bluetooth devices, 'Amp' will show as one of those. This command is sent as 'AT', followed by a plus sign, then the parameter in uppercase (NAME) and then the value.
The second command is to set a PIN, as I didn't want to use the default (since living in a block of flats, you have many people around). 4321 is the PIN example I've shown below.
Other AT commands are possible, such as upping the baud rate etc.
I think I explained most of the code. The whole lot is only about 1,300 lines of code and as mentioned before, it needs only 5198 bytes of flash/code ROM, 197 bytes of RAM and runs at a clock speed of 10MHz. It has evolved and reduced from the original I did in 2004 though and it has been interesting and enjoyable to code and test and really gives a different programming experience that most programmers are used to.
The result is the control of my amplifier is convenient and reliable, with ease of use by a simple IR remote control, and further settings configured using a Bluetooth application.
Read on for a description of how I made an Android application to allow the control of amplifier, from my phone.
References and more reading:
PIC 18F4455 Datasheet
Dring Engineering Services - IC Timer Calculator and Source Code Generator
Tamil Arduino - IR RC5 decoding code using interrupts
CCS - 7-segment LED display in 3-digit with 2x7W amplifier
ASCII Codes Table