├── .cargo └── config.toml ├── .gitignore ├── .vscode └── settings.json ├── Cargo.toml ├── LICENSE ├── README.md ├── build.rs ├── clock.fzz ├── clock.py ├── figures ├── breadboard.jpg ├── clock_front.jpg ├── clock_rear.jpg ├── coffee-play.jpg ├── coffee.jpg ├── debugging.png ├── fritzing.png ├── scope.jpg └── states.png ├── memory.x └── src └── main.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.'cfg(all(target_arch = "arm", target_os = "none"))'] 2 | # Choose a default "cargo run" tool (see README for more info) 3 | # - `probe-rs` provides flashing and defmt via a hardware debugger, and stack unwind on panic 4 | # - elf2uf2-rs loads firmware over USB when the rp2040 is in boot mode 5 | # runner = "probe-rs run --chip RP2040 --protocol swd" 6 | runner = "elf2uf2-rs -d" 7 | 8 | rustflags = [ 9 | "-C", 10 | "linker=flip-link", 11 | "-C", 12 | "link-arg=--nmagic", 13 | "-C", 14 | "link-arg=-Tlink.x", 15 | 16 | # Code-size optimizations. 17 | # trap unreachable can save a lot of space, but requires nightly compiler. 18 | # uncomment the next line if you wish to enable it 19 | # "-Z", "trap-unreachable=no", 20 | "-C", 21 | "no-vectorize-loops", 22 | ] 23 | 24 | [build] 25 | target = "thumbv6m-none-eabi" 26 | 27 | [env] 28 | DEFMT_LOG = "debug" 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/*.rs.bk 2 | .#* 3 | .gdb_history 4 | Cargo.lock 5 | target/ 6 | 7 | # editor files 8 | .vscode/* 9 | !.vscode/*.md 10 | !.vscode/*.svd 11 | !.vscode/launch.json 12 | !.vscode/tasks.json 13 | !.vscode/extensions.json 14 | !.vscode/settings.json -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.cargo.target": "thumbv6m-none-eabi", 3 | "rust-analyzer.check.allTargets": false, 4 | "editor.formatOnSave": true 5 | } 6 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | edition = "2021" 3 | name = "ventinari-clock" 4 | version = "0.1.0" 5 | license = "MIT" 6 | 7 | [dependencies] 8 | cortex-m = "0.7" 9 | cortex-m-rt = "0.7" 10 | embedded-hal = { version = "1.0.0" } 11 | panic-halt = "1.0.0" 12 | waveshare-rp2040-zero = "0.8.0" 13 | rp2040-hal = { version = "0.11.0", features = ["binary-info"] } 14 | usb-device = "0.3.2" 15 | usbd-serial = "0.2.2" 16 | heapless = "0.8.0" 17 | 18 | # cargo build/run 19 | [profile.dev] 20 | codegen-units = 1 21 | debug = 2 22 | debug-assertions = true 23 | incremental = false 24 | opt-level = 3 25 | overflow-checks = true 26 | 27 | 28 | # cargo build/run --release 29 | [profile.release] 30 | codegen-units = 1 31 | debug = 2 32 | debug-assertions = false 33 | incremental = false 34 | lto = 'fat' 35 | opt-level = 3 36 | overflow-checks = false 37 | 38 | # do not optimize proc-macro crates = faster builds from scratch 39 | [profile.dev.build-override] 40 | codegen-units = 8 41 | debug = false 42 | debug-assertions = false 43 | opt-level = 0 44 | overflow-checks = false 45 | 46 | [profile.release.build-override] 47 | codegen-units = 8 48 | debug = false 49 | debug-assertions = false 50 | opt-level = 0 51 | overflow-checks = false 52 | 53 | # cargo test 54 | [profile.test] 55 | codegen-units = 1 56 | debug = 2 57 | debug-assertions = true 58 | incremental = false 59 | opt-level = 3 60 | overflow-checks = true 61 | 62 | # cargo test --release 63 | [profile.bench] 64 | codegen-units = 1 65 | debug = 2 66 | debug-assertions = false 67 | incremental = false 68 | lto = 'fat' 69 | opt-level = 3 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 iracigt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ventinari Clock: Functional Malfunctional Timekeeping 2 | 3 | **Date of Last Calibration: April 1, 2025** 4 | 5 | ![a normal looking wall clock](figures/clock_front.jpg) 6 | 7 | # The Goal 8 | 9 | Ever since I saw a [Hackaday post](https://hackaday.com/2011/10/06/vetinari-clock-will-drive-you-insane/) many many years ago about a clock designed to drive you mad, I knew I wanted one. Last fall, I decided the time would be right on [April 1st, 2025](https://en.wikipedia.org/wiki/April_Fools%27_Day). I blessed my computer science research lab with a clock that is clearly erratic, yet keeps time just fine. Installed the night before, it graced the wall above the coffee machine and presided over undergraduate office hours. 10 | 11 | This is the result. A 5 dollar clock from Target with a Pi Pico (RP2040) clone as the brains, designed to tick randomly with a average rate of one tick per second. This project is quick and dirty, banged out in an evening for only one day in the limelight. Shortcuts were taken, power was not optimized, and the overall mantra was "good enough". Oh and it's written in Rust, because why not? I also do research on strongly typed programming languages, so maybe I'm biased here. 12 | 13 | The code's all here and a build-log follows below. If you feel like you need this in your (or your victim's) life, the design is for the taking. Would I recommend using my code? No. Will it break after a few days because I'm abusing both of the two components? Maybe. Can you do it anyway? Sure, why not. 14 | 15 | # Driving a Clock 16 | 17 | The first task is to control the clock from a microcontroller. Luckily your typical quartz clocks are very very simple. Buried inside the plastic housing, nestled beneath quite a chain of tiny plastic gears, is a minimal PCB. On it lives a epoxy blob IC, a trimmed quartz crystal, invariably 32.768 kHz, and a solenoid-like coil wrapped around a metal core. This chip's purpose in life is to divide by 32768 (aka 2^15) and create a pulse through the coil once per second. These pulses reverse polarity every tick. The effect is to turn a tiny rotor 180 degrees each time. The gearwork translates this into the motion of the hour, minute, and second hands. 18 | 19 | Thus to drive the clock programmatically, we need to supply these pulses. The alternating polarity is needed to flip the rotor around each time. Without reversing it, the rotor would align with the field of this electromagnet and then stay put. The _correct_ way to do this is with an H-bridge. That sounds like effort, so we'll just kludge it in software. Each end of the coil goes to a GPIO pin on the RP2040. These are kept low (push-pull mode), and on each tick we pulse one high. By alternating which pin goes high, we create the alternate polarity pulses the coil needs. On the oscilloscope, it looks like this: 20 | 21 | ![two staggered pulse trains on an oscilloscope](figures/scope.jpg) 22 | 23 | To give the chip a little cushion from the inductive load, I also threw in a 100 ohm resistor in series. These clockworks are designed to be low power. The coil doesn't need that much current to tick. To make things a little smoother, I also reduced the GPIO drive strength to 2 mA --- the lowest setting on the RP2040. I'm still driving this a little hard, as evidenced by the bounce in the oscilloscope traces. I want a nice chonky tick sound, so I'll take the risk. I had the whole thing disassembled (but forgot to take pictures), so tacking some tiny wires to the coil's PCB pads was a 30 second adventure with the soldering iron. Below I've used the pinnacle of hardware design software, Fritzing, to show exactly how simple this all really is. With that done, we've now got control over the ticks. 24 | 25 | ![fritzing diagram of the mcu driving the coil](figures/fritzing.png) 26 | 27 | # Keeping Time With Markov Chains 28 | 29 | With the tick out of the way, next comes the randomness. I wanted something more erratic than subtle so the clock gets some attention. Simplicity and expediency are the goals, so I didn't want a complicated algorithm. We need the clock to keep reasonable time for about 24 hours. An ideal algorithm wouldn't rely on tracking the drift or history, be as simple as a lookup table, and be easy to verify and tune. Clearly the solution is AI --- _retro AI_. 30 | 31 | If you remember the spam of the 00's and the gibberish inside designed to fool statistical spam filters, you're familiar with the venerable Markov chain. It's just a state machine, where the transitions are probabilistic. Using the tiniest bit of linear algebra, we can compute the frequency of each state. I settled on a 4-state model where all the transition probabilities are fractions with a denominator of 16. This means taking a step just needs a 4x16 lookup table and a 4-bit random number. The base state machine with just the "normal" transitions looks like this: 32 | 33 | ![a 4 state loop](figures/states.png) 34 | 35 | Now we just need to add the wacky edges. First though, we need a matrix. I'm sure there's some fancy method for computing one that keeps good time. I went with the classic "change the numbers and see what happens". It's all about the vibes. Waiting around to measure the drift was a little too slow. So I threw together a Python script to do some math and give me a little simulated clock sound. The mechanism is simple. We randomly transition four times per second and tick whenever we hit state 0. 36 | 37 | To determine the average drift, we just need to know what the ratio of state 0 is to the total number of transitions. Markov made this easy. Multiplying a vector representing the probability of being in each state by the transition matrix computes the probability of each following state. The tiny bit of linear algebra tells us that exponentiating the transition matrix converges to the steady state probabilities. In practice, this means multiplying the matrix by itself a bunch of times will tell us how often we enter each state. Using `numpy.linalg.matrix_power(trans, 1000)` and summing up the first column gives us the average tick rate. The simulation is in [`clock.py`](clock.py). 38 | 39 | Using this and a pygame simulation that plays a tick, I can see both the average drift and get a feel for the randomness. I didn't bother working out the math to calculate the variance. Python can run a few dozen 1-day simulations and give me a feel for the spread. I'm perfectly happy with a minute or two of drift during the 36 hour run. Poking around for an hour, I settled on the following transition matrix. The ticking is erratic and unpredictable, yet it all averages out to one tick per second. 40 | 41 | $$ T = 42 | \begin{bmatrix} 43 | \frac{ 1}{16} & \frac{14}{16} & \frac{ 0}{16} &\frac{ 1}{16} \\ 44 | \frac{ 1}{16} & \frac{ 1}{16} & \frac{13}{16} &\frac{ 1}{16} \\ 45 | \frac{ 0}{16} & \frac{ 0}{16} & \frac{ 2}{16} &\frac{14}{16} \\ 46 | \frac{14}{16} & \frac{ 1}{16} & \frac{ 1}{16} &\frac{ 0}{16} \\ 47 | \end{bmatrix} 48 | $$ 49 | 50 | # Implementation 51 | 52 | The code is bad. I'm not going to claim otherwise. The transition matrix becomes a 4x16 lookup table. A linear-feedback shift register provides the randomness. I just grabbed the example constants off Wikipedia. Using the PWM peripheral as a timer, we generate an interrupt at 8 Hz. Every other interrupt, we grab 4 bits of randomness from the LFSR. This becomes an index into the current state row of the transition lookup table. The result is the next state. Since all of the probabilities have a denominator of 16, we the numerator tells us how many times that state should appear in the row. This gives us our weighted random transition. 53 | 54 | If the lookup sends us to state zero, we need to tick the second hand. We keep a flag to track the alternating polarity and drive the corresponding pin high. Every other interrupt sets both low. The result is a 125 ms pulse, very close to the original clock waveform. 55 | For debugging, I also blink an LED and log the number of times we enter each state over a USB serial port. Is this a lot for an interrupt handler? Yes. Do I care? No. 56 | 57 | Getting things up and running on the bench, with an LED for visual feedback: 58 | ![a clock with wires coming out connected to a mini breadboard with a microcontroller](figures/debugging.png) 59 | 60 | I'm going to take a quick aside and rant about GitHub Copilot and using LLMs to program embedded systems. In short, I tried it and it was a bad experience. I'm working in Rust so the available training data is slim compared to Arduino. I constantly got code that used nonexistent library functions. Setting up IRQs, Copilot made multiple mistakes that I wouldn't have if I just looked at the examples and followed manually. Debugging with vibes doesn't work out so well when the only feedback I have is "does nothing". Copilot didn't know to unmask the interrupts and I forgot on account of not writing embedded Rust for a few months. Had I followed the examples from `rp-hal` or even my own previous code, I would have done it. Copilot also messed up a common lazy initialization idiom in Rust interrupts to manage ownership of peripherals. By omitting an initialization check, it overwrote the state and skipped the entire body of my interrupt handler. Yet another thing that just a copy-paste from my old code would have avoided. Since this is embedded work and in my hubris I chose the board without a debugger, the end result is just "does nothing". Copilot can certainly write code, it just doesn't work. 61 | 62 | # Assembly 63 | 64 | The clock ticks, the math is done, and the code is written. It's time for assembly. I'm installing this the night before so it can be experienced all day --- and I'm more likely to remain anonymous at first. Thus I need it to run for about 36 hours. Power optimization takes effort, but thankfully it's not going to be necessary. My NiMH AA batteries hold about 2200 mAh each. Four of those will supply the onboard regulator with 5V-ish. Using the multimeter, I'm drawing around 25 mA. Crunch those numbers and you get 3 days of runtime, with a 25% margin. So a 4 AA battery pack it is. In the spirit of "good enough", the wiring is managed via a mini-breadboard stuck to the back. It makes probing to debug easy, the mounting hardware is a loop of tape, and I can reclaim the microcontroller when I'm done. 65 | 66 | ![the microcontroller on a mini breadboard, adhered to the back of the clock](figures/breadboard.jpg) 67 | 68 | I hit one small bug where an off-by-one in my LFSR random number generator was only giving me three bit numbers instead of four. This led only using half of the transition matrix and ran about 10% fast. In the course of adding some debug logging I spotted and corrected that. With that bug squashed, everything seems to be working as intended. A medium length dry run had it keeping time within a couple seconds over 6 hours. The Pi Pico crystal is rated for +/- 30 ppm, or under three seconds per day. I could measure my specific crystal and trim it in software against a reference, but this is _good enough_. I didn't calculate the variance of the ticks, but some simulations suggest the randomness itself is adding a several seconds per day of error anyway. 69 | 70 | ![the back of the clock showing the breadboard, battery pack, and wires going into the clock movement](figures/clock_rear.jpg) 71 | 72 | # Installation 73 | 74 | There was zero chance I'd be the first one in the lab on a Tuesday morning --- or any morning. Thus the installation occurred the night before. The coffee maker was the perfect target. Not only is it the natural watering hole for a pod of CS faculty and grad students, but it also overlooks the area used for undergraduate office hours. Maximum coverage for maximum impact. With some fresh batteries in the back, up on the wall it went. Now comes the hard part: keeping quiet and enjoying the experience. There's a reason I don't play poker. 75 | 76 | ![the clock installed on the wall](figures/coffee.jpg) 77 | 78 | I'm writing this the night before, so I'll have to wait until morning to collect reactions. I intend to (unconvincingly tbh) play dumb until at least afternoon. My late arrival will be to my advantage here. Just in case inquisitive minds attempt a repair, I've helpfully labeled the back with the date of last servicing: April 1, 2025. So everyone can rest assured, the clock is behaving as it should. 79 | 80 | Here's a short YouTube video of the clock in action. Turn sound up to hear the ticking and get the full effect! 81 | 82 | [![a link to the youtube video](figures/coffee-play.jpg)](https://youtu.be/3vpgnc2ZdwQ) 83 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | //! This build script copies the `memory.x` file from the crate root into 2 | //! a directory where the linker can always find it at build time. 3 | //! For many projects this is optional, as the linker always searches the 4 | //! project root directory -- wherever `Cargo.toml` is. However, if you 5 | //! are using a workspace or have a more complicated build setup, this 6 | //! build script becomes required. Additionally, by requesting that 7 | //! Cargo re-run the build script whenever `memory.x` is changed, 8 | //! updating `memory.x` ensures a rebuild of the application with the 9 | //! new memory settings. 10 | 11 | use std::env; 12 | use std::fs::File; 13 | use std::io::Write; 14 | use std::path::PathBuf; 15 | 16 | fn main() { 17 | // Put `memory.x` in our output directory and ensure it's 18 | // on the linker search path. 19 | let out = &PathBuf::from(env::var_os("OUT_DIR").unwrap()); 20 | File::create(out.join("memory.x")) 21 | .unwrap() 22 | .write_all(include_bytes!("memory.x")) 23 | .unwrap(); 24 | println!("cargo:rustc-link-search={}", out.display()); 25 | 26 | // By default, Cargo will re-run a build script whenever 27 | // any file in the project changes. By specifying `memory.x` 28 | // here, we ensure the build script is only re-run when 29 | // `memory.x` is changed. 30 | println!("cargo:rerun-if-changed=memory.x"); 31 | } 32 | -------------------------------------------------------------------------------- /clock.fzz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iracigt/ventinari-clock/025da7b75f03132f9fc6a29caf557737beb0638e/clock.fzz -------------------------------------------------------------------------------- /clock.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | import numpy as np 3 | import time 4 | from datetime import datetime 5 | 6 | 7 | # State machine 8 | state = 0 9 | 10 | # transitions = np.array([ 11 | # [ 1, 14, 0, 1 ], 12 | # [ 2, 1, 13, 0 ], 13 | # [ 0, 2, 0, 14 ], 14 | # [ 14, 1, 1, 0 ], 15 | # ])/16 16 | 17 | # basically speed=1.0 18 | transitions = np.array([ 19 | [ 1, 14, 0, 1 ], 20 | [ 1, 1, 13, 1 ], 21 | [ 0, 0, 2, 14 ], 22 | [ 14, 1, 1, 0 ], 23 | ])/16 24 | 25 | 26 | acc = np.zeros(4) 27 | 28 | # Compute steady state transition matrix 29 | steady_state = np.linalg.matrix_power(transitions, 1000) 30 | print(steady_state) 31 | print("speed =", np.sum(steady_state[:,0])) 32 | print("avg drift / day =", (np.sum(steady_state[:,0]) - 1)* 3600*24) 33 | 34 | for _ in range(20): 35 | for i in range(60*4): 36 | state = np.where(np.cumsum(transitions[state]) >= np.random.rand())[0][0] 37 | acc[state] += 1 38 | print('simulated minute', acc[0] - 60, acc[0] / np.sum(acc) * 4, acc / np.sum(acc)) 39 | acc = np.zeros(4) 40 | 41 | for i in range(3600*24*4): 42 | state = np.where(np.cumsum(transitions[state]) >= np.random.rand())[0][0] 43 | acc[state] += 1 44 | print('simulated day', acc[0] - 3600*24, acc[0] / np.sum(acc) * 4, acc / np.sum(acc)) 45 | acc = np.zeros(4) 46 | 47 | # Initialize pygame 48 | pygame.init() 49 | 50 | # Screen dimensions 51 | WIDTH, HEIGHT = 800, 600 52 | screen = pygame.display.set_mode((WIDTH, HEIGHT)) 53 | pygame.display.set_caption("Clock") 54 | 55 | # Fonts and colors 56 | font = pygame.font.Font(None, 256) 57 | bg_color = (0, 0, 0) 58 | text_color = (255, 255, 255) 59 | 60 | # Generate click sound 61 | sound_array = np.int16(np.sin(np.linspace(0, 4*np.pi, 100)) * 2**14) 62 | stereo_array = np.column_stack((sound_array, sound_array)) # Make it 2D for stereo 63 | click_sound = pygame.mixer.Sound(buffer=pygame.sndarray.make_sound(stereo_array)) 64 | 65 | # Clock for controlling frame rate 66 | clock = pygame.time.Clock() 67 | 68 | running = True 69 | last_second = -1 70 | 71 | while running: 72 | for event in pygame.event.get(): 73 | if event.type == pygame.QUIT: 74 | running = False 75 | 76 | # Get current time 77 | now = datetime.now() 78 | current_time = now.strftime("%H:%M:%S") 79 | 80 | 81 | # Update the state machine 82 | state = np.where(np.cumsum(transitions[state]) >= np.random.rand())[0][0] 83 | acc[state] += 1 84 | print(acc[0] / np.sum(acc) * 4, acc / np.sum(acc)) 85 | 86 | if state == 0: 87 | click_sound.play() 88 | text_color = (255, 255, 255) 89 | elif state == 1: 90 | text_color = (255, 0, 0) 91 | elif state == 2: 92 | text_color = (0, 255, 0) 93 | elif state == 3: 94 | text_color = (0, 0, 255) 95 | 96 | # Check if the second has changed 97 | if now.second != last_second: 98 | last_second = now.second 99 | 100 | # Draw the current time 101 | screen.fill(bg_color) 102 | text_surface = font.render(str(state), True, text_color) 103 | text_rect = text_surface.get_rect(center=(WIDTH // 2, HEIGHT // 2)) 104 | screen.blit(text_surface, text_rect) 105 | 106 | # Update the display 107 | pygame.display.flip() 108 | 109 | # Cap the frame rate 110 | clock.tick(4) 111 | 112 | 113 | 114 | pygame.quit() -------------------------------------------------------------------------------- /figures/breadboard.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iracigt/ventinari-clock/025da7b75f03132f9fc6a29caf557737beb0638e/figures/breadboard.jpg -------------------------------------------------------------------------------- /figures/clock_front.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iracigt/ventinari-clock/025da7b75f03132f9fc6a29caf557737beb0638e/figures/clock_front.jpg -------------------------------------------------------------------------------- /figures/clock_rear.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iracigt/ventinari-clock/025da7b75f03132f9fc6a29caf557737beb0638e/figures/clock_rear.jpg -------------------------------------------------------------------------------- /figures/coffee-play.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iracigt/ventinari-clock/025da7b75f03132f9fc6a29caf557737beb0638e/figures/coffee-play.jpg -------------------------------------------------------------------------------- /figures/coffee.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iracigt/ventinari-clock/025da7b75f03132f9fc6a29caf557737beb0638e/figures/coffee.jpg -------------------------------------------------------------------------------- /figures/debugging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iracigt/ventinari-clock/025da7b75f03132f9fc6a29caf557737beb0638e/figures/debugging.png -------------------------------------------------------------------------------- /figures/fritzing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iracigt/ventinari-clock/025da7b75f03132f9fc6a29caf557737beb0638e/figures/fritzing.png -------------------------------------------------------------------------------- /figures/scope.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iracigt/ventinari-clock/025da7b75f03132f9fc6a29caf557737beb0638e/figures/scope.jpg -------------------------------------------------------------------------------- /figures/states.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iracigt/ventinari-clock/025da7b75f03132f9fc6a29caf557737beb0638e/figures/states.png -------------------------------------------------------------------------------- /memory.x: -------------------------------------------------------------------------------- 1 | MEMORY { 2 | BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100 3 | /* 4 | * Here we assume you have 2048 KiB of Flash. This is what the Pi Pico 5 | * has, but your board may have more or less Flash and you should adjust 6 | * this value to suit. 7 | */ 8 | FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100 9 | /* 10 | * RAM consists of 4 banks, SRAM0-SRAM3, with a striped mapping. 11 | * This is usually good for performance, as it distributes load on 12 | * those banks evenly. 13 | */ 14 | RAM : ORIGIN = 0x20000000, LENGTH = 256K 15 | /* 16 | * RAM banks 4 and 5 use a direct mapping. They can be used to have 17 | * memory areas dedicated for some specific job, improving predictability 18 | * of access times. 19 | * Example: Separate stacks for core0 and core1. 20 | */ 21 | SRAM4 : ORIGIN = 0x20040000, LENGTH = 4k 22 | SRAM5 : ORIGIN = 0x20041000, LENGTH = 4k 23 | 24 | /* SRAM banks 0-3 can also be accessed directly. However, those ranges 25 | alias with the RAM mapping, above. So don't use them at the same time! 26 | SRAM0 : ORIGIN = 0x21000000, LENGTH = 64k 27 | SRAM1 : ORIGIN = 0x21010000, LENGTH = 64k 28 | SRAM2 : ORIGIN = 0x21020000, LENGTH = 64k 29 | SRAM3 : ORIGIN = 0x21030000, LENGTH = 64k 30 | */ 31 | } 32 | 33 | EXTERN(BOOT2_FIRMWARE) 34 | 35 | SECTIONS { 36 | /* ### Boot loader 37 | * 38 | * An executable block of code which sets up the QSPI interface for 39 | * 'Execute-In-Place' (or XIP) mode. Also sends chip-specific commands to 40 | * the external flash chip. 41 | * 42 | * Must go at the start of external flash, where the Boot ROM expects it. 43 | */ 44 | .boot2 ORIGIN(BOOT2) : 45 | { 46 | KEEP(*(.boot2)); 47 | } > BOOT2 48 | } INSERT BEFORE .text; 49 | 50 | SECTIONS { 51 | /* ### Boot ROM info 52 | * 53 | * Goes after .vector_table, to keep it in the first 512 bytes of flash, 54 | * where picotool can find it 55 | */ 56 | .boot_info : ALIGN(4) 57 | { 58 | KEEP(*(.boot_info)); 59 | } > FLASH 60 | 61 | } INSERT AFTER .vector_table; 62 | 63 | /* move .text to start /after/ the boot info */ 64 | _stext = ADDR(.boot_info) + SIZEOF(.boot_info); 65 | 66 | SECTIONS { 67 | /* ### Picotool 'Binary Info' Entries 68 | * 69 | * Picotool looks through this block (as we have pointers to it in our 70 | * header) to find interesting information. 71 | */ 72 | .bi_entries : ALIGN(4) 73 | { 74 | /* We put this in the header */ 75 | __bi_entries_start = .; 76 | /* Here are the entries */ 77 | KEEP(*(.bi_entries)); 78 | /* Keep this block a nice round size */ 79 | . = ALIGN(4); 80 | /* We put this in the header */ 81 | __bi_entries_end = .; 82 | } > FLASH 83 | } INSERT AFTER .text; -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![no_std] 2 | #![no_main] 3 | 4 | use core::{cell::RefCell, fmt::Write, sync::atomic::AtomicU32}; 5 | 6 | use bsp::entry; 7 | use cortex_m::interrupt::Mutex; 8 | use embedded_hal::{ 9 | digital::{OutputPin, StatefulOutputPin as _}, 10 | pwm::SetDutyCycle, 11 | }; 12 | use panic_halt as _; 13 | 14 | use bsp::hal::{ 15 | clocks::{init_clocks_and_plls, Clock}, 16 | gpio::bank0::{Gpio14, Gpio15, Gpio28}, 17 | gpio::{FunctionSio, Pin, PullDown, SioOutput}, 18 | pac, 19 | pac::interrupt, 20 | pwm::{FreeRunning, Pwm6, Slice}, 21 | sio::Sio, 22 | watchdog::Watchdog, 23 | }; 24 | 25 | use rp2040_hal::binary_info; 26 | use usb_device::{ 27 | bus::UsbBusAllocator, 28 | device::{StringDescriptors, UsbDeviceBuilder, UsbDeviceState, UsbVidPid}, 29 | }; 30 | use usbd_serial::SerialPort; 31 | use waveshare_rp2040_zero::{ 32 | self as bsp, 33 | hal::{gpio::OutputDriveStrength, usb::UsbBus}, 34 | }; 35 | 36 | use heapless::String; 37 | 38 | type IrqVars = ( 39 | Slice, 40 | Pin, PullDown>, 41 | Pin, PullDown>, 42 | Pin, PullDown>, 43 | ); 44 | 45 | static GLOBAL_PWM: Mutex>> = Mutex::new(RefCell::new(None)); 46 | 47 | // [ 1, 14, 0, 1 ], 48 | // [ 1, 1, 13, 1 ], 49 | // [ 0, 0, 2, 14 ], 50 | // [ 14, 1, 1, 0 ], 51 | 52 | static TRANSITION_MATRIX: [[usize; 16]; 4] = [ 53 | [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3], 54 | [0, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3], 55 | [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3], 56 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2], 57 | ]; 58 | 59 | static TRANSITION_MATRIX_STEADY: [[usize; 16]; 4] = [ 60 | [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], 61 | [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], 62 | [3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3], 63 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 64 | ]; 65 | 66 | static TRANSITION_COUNT: [AtomicU32; 4] = [ 67 | AtomicU32::new(0), 68 | AtomicU32::new(0), 69 | AtomicU32::new(0), 70 | AtomicU32::new(0), 71 | ]; 72 | 73 | const LG_SUBSTATES: usize = 1; 74 | 75 | #[entry] 76 | fn main() -> ! { 77 | let mut pac = pac::Peripherals::take().unwrap(); 78 | let core = pac::CorePeripherals::take().unwrap(); 79 | let mut watchdog = Watchdog::new(pac.WATCHDOG); 80 | let sio = Sio::new(pac.SIO); 81 | 82 | // External high-speed crystal on the pico board is 12Mhz 83 | let external_xtal_freq_hz = 12_000_000u32; 84 | let clocks = init_clocks_and_plls( 85 | external_xtal_freq_hz, 86 | pac.XOSC, 87 | pac.CLOCKS, 88 | pac.PLL_SYS, 89 | pac.PLL_USB, 90 | &mut pac.RESETS, 91 | &mut watchdog, 92 | ) 93 | .ok() 94 | .unwrap(); 95 | 96 | let mut delay = cortex_m::delay::Delay::new(core.SYST, clocks.system_clock.freq().to_Hz()); 97 | 98 | let pins = bsp::Pins::new( 99 | pac.IO_BANK0, 100 | pac.PADS_BANK0, 101 | sio.gpio_bank0, 102 | &mut pac.RESETS, 103 | ); 104 | 105 | let led_pin = pins.gp28.into_push_pull_output(); 106 | let mut pin14 = pins.gp14.into_push_pull_output(); 107 | let mut pin15 = pins.gp15.into_push_pull_output(); 108 | 109 | pin14.set_drive_strength(OutputDriveStrength::TwoMilliAmps); 110 | pin15.set_drive_strength(OutputDriveStrength::TwoMilliAmps); 111 | 112 | // Configure PWM with a period of 250 milliseconds 113 | let pwm_slices = bsp::hal::pwm::Slices::new(pac.PWM, &mut pac.RESETS); 114 | let mut pwm = pwm_slices.pwm6; 115 | // 2 us per tick @ 125 MHz 116 | pwm.set_div_int(125); 117 | pwm.set_ph_correct(); 118 | pwm.output_to(pins.gp29); 119 | pwm.channel_b.set_duty_cycle_percent(50).unwrap(); 120 | // 125 ms period = 8 Hz 121 | pwm.set_top(62_500); 122 | pwm.enable_interrupt(); 123 | pwm.enable(); 124 | 125 | let usb_bus = UsbBusAllocator::new(UsbBus::new( 126 | pac.USBCTRL_REGS, 127 | pac.USBCTRL_DPRAM, 128 | clocks.usb_clock, 129 | true, 130 | &mut pac.RESETS, 131 | )); 132 | 133 | // Set up the USB Communications Class Device driver 134 | let mut serial = SerialPort::new(&usb_bus); 135 | 136 | // Create a USB device with a fake VID and PID 137 | let mut usb_dev = UsbDeviceBuilder::new(&usb_bus, UsbVidPid(0x2E8A, 0x0004)) 138 | .strings(&[StringDescriptors::default() 139 | .manufacturer("Aperture Science Laboratories") 140 | .product("Autonomous Temporal Indicator") 141 | .serial_number("VENTINARI")]) 142 | .unwrap() 143 | .device_class(2) 144 | .build(); 145 | 146 | cortex_m::interrupt::free(|cs| { 147 | GLOBAL_PWM 148 | .borrow(cs) 149 | .replace(Some((pwm, led_pin, pin14, pin15))); 150 | }); 151 | 152 | // Unmask the PWM interrupt 153 | unsafe { 154 | pac::NVIC::unmask(pac::Interrupt::PWM_IRQ_WRAP); 155 | } 156 | 157 | loop { 158 | // Wait for the interrupt to be triggered 159 | cortex_m::asm::wfi(); 160 | 161 | let mut buf: String<32> = String::new(); 162 | 163 | if usb_dev.poll(&mut [&mut serial]) {} 164 | 165 | if usb_dev.state() == UsbDeviceState::Configured { 166 | let count_0 = TRANSITION_COUNT[0].load(core::sync::atomic::Ordering::SeqCst); 167 | let count_1 = TRANSITION_COUNT[1].load(core::sync::atomic::Ordering::SeqCst); 168 | let count_2 = TRANSITION_COUNT[2].load(core::sync::atomic::Ordering::SeqCst); 169 | let count_3 = TRANSITION_COUNT[3].load(core::sync::atomic::Ordering::SeqCst); 170 | let total = count_0 + count_1 + count_2 + count_3; 171 | let ratio = 4.0 * count_0 as f32 / total as f32; 172 | 173 | write!( 174 | &mut buf, 175 | "{:0.03} {} {} {} {} {}\r\n", 176 | ratio, count_0, count_1, count_2, count_3, total 177 | ) 178 | .ok(); 179 | serial.write(buf.as_bytes()).ok(); 180 | } 181 | } 182 | } 183 | 184 | #[interrupt] 185 | fn PWM_IRQ_WRAP() { 186 | static mut VALS: Option = None; 187 | static mut STATE: usize = 0; 188 | static mut LFSR_STATE: u32 = 0xACE1; 189 | static mut TOCK: bool = false; 190 | 191 | // Update the LFSR state 192 | let mut rand4 = 0usize; 193 | for _ in 0..4 { 194 | let lsb = *LFSR_STATE & 1; 195 | *LFSR_STATE >>= 1; 196 | 197 | if lsb != 0 { 198 | *LFSR_STATE ^= 0xB400; 199 | } 200 | 201 | rand4 = (rand4 << 1) | (lsb as usize); 202 | } 203 | 204 | // This is one-time lazy initialization 205 | if VALS.is_none() { 206 | cortex_m::interrupt::free(|cs| { 207 | *VALS = GLOBAL_PWM.borrow(cs).take(); 208 | }); 209 | } 210 | 211 | if let Some((pwm, led, pin14, pin15)) = VALS { 212 | pwm.clear_interrupt(); 213 | 214 | *STATE += 1; 215 | if *STATE % (1 << LG_SUBSTATES) == 0 { 216 | *STATE = TRANSITION_MATRIX[(*STATE >> LG_SUBSTATES) - 1][rand4] << LG_SUBSTATES; 217 | 218 | let index = (*STATE >> LG_SUBSTATES) as usize; 219 | let current = TRANSITION_COUNT[index].load(core::sync::atomic::Ordering::SeqCst); 220 | TRANSITION_COUNT[index].store(current + 1, core::sync::atomic::Ordering::SeqCst); 221 | } 222 | 223 | if *STATE == 0 { 224 | led.set_high().unwrap(); 225 | if *TOCK { 226 | pin14.set_high().unwrap(); 227 | pin15.set_low().unwrap(); 228 | } else { 229 | pin14.set_low().unwrap(); 230 | pin15.set_high().unwrap(); 231 | } 232 | *TOCK = !*TOCK; 233 | } else { 234 | pin14.set_low().unwrap(); 235 | pin15.set_low().unwrap(); 236 | led.set_low().unwrap(); 237 | } 238 | } 239 | } 240 | 241 | #[link_section = ".bi_entries"] 242 | #[used] 243 | pub static PICOTOOL_ENTRIES: [binary_info::EntryAddr; 6] = [ 244 | binary_info::rp_program_name!(c"Ventinari's clock"), 245 | binary_info::rp_cargo_version!(), 246 | binary_info::rp_program_description!(c"A stuttering clock"), 247 | binary_info::rp_program_url!(c"https://github.com/iracigt/ventinari-clock"), 248 | binary_info::rp_program_build_attribute!(), 249 | binary_info::rp_pico_board!(c"pico"), 250 | ]; 251 | --------------------------------------------------------------------------------