diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index dd30cf6..7dda894 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,9 @@ debug_*.py dist/ build/ *.egg-info/ + +# Auto-added by Marisol pipeline +.pio/ +.gradle/ +*.class +local.properties diff --git a/PiDSLR.fzz b/PiDSLR.fzz old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 index ff81653..3de085f --- a/README.md +++ b/README.md @@ -1,54 +1,217 @@ -piDSLM - Raspberry Pi Digital Single Lens Mirrorless -=============== +# piDSLM - Raspberry Pi Digital Single Lens Mirrorless -Camera project for Raspberry Pi 2/3 + HQ Camera + MHS35-TFT +![piDSLM Interface](https://i.imgur.com/VspFA5V.jpg) - +A standalone battery-powered DSLM camera interface for Raspberry Pi 2/3 with HQ Camera and MHS35-TFT display. -# Introduction +## Features -Made an enclosure to host the [HQ Raspberry Pi Camera](https://www.raspberrypi.org/products/raspberry-pi-high-quality-camera/) as a standalone battery-powered DSLM that I'm calling piDSLM. Check out the links below for instructions on how to recreate the project! +- **Photo Capture**: Take high-quality photos using the Raspberry Pi HQ Camera +- **Video Recording**: Record HD video (30s clips) or split 30-minute sessions +- **Timelapse**: Capture timelapse photos with 60-second intervals over 1 hour +- **Burst Mode**: Capture rapid sequential photos (up to 10,000ms) +- **Gallery View**: Browse captured photos with left/right navigation +- **Long Preview**: 15-second preview mode +- **Dropbox Upload**: Bulk upload footage to Dropbox cloud storage +- **GPIO Control**: Button-activated photo capture with pin 16 +- **Clear Function**: Delete all files from Downloads folder -The design includes a few modular camera grips for users. Feel free to make your own designs and reach out to me so I can include them! - -For More Info: +## Project Links - [Hackster](https://www.hackster.io/projects/2a86c3) - [GitHub](https://github.com/NickEngmann/piDSLM) -- [Instructables] ( TBD ) +- [OnShape Design](https://bit.ly/raspi-onshape) + +The enclosure design is modulare—feel free to make your own designs and reach out to include them! + +## Installation + +### Prerequisites + +- Raspberry Pi 2 or 3 (Pi 4 may work with modifications) +- Raspberry Pi HQ Camera +- MHS35-TFT Display (3.5" HDMI LCD) +- Battery pack for portable operation + +### Setup + +1. Clone the repository and navigate to the directory: + ```bash + git clone https://github.com/NickEngmann/piDSLM.git + cd pidslm + ``` + +2. **Configure Dropbox Access Token**: + - Go to the [Dropbox Developers page](https://www.dropbox.com/developers/apps) + - Create a new application + - Generate an Access Token + - Edit `dropbox_upload.py` and replace the placeholder token: + ```python + TOKEN = 'YOUR_ACCESS_TOKEN' # Replace with your token + ``` + +3. **Run the Installation Script**: + ```bash + sudo ./INSTALL.sh + ``` + This will: + - Install Python dependencies (Pillow, guizero, dropbox SDK) + - Set up auto-start configuration + - Configure camera settings in `/boot/config.txt` + - Reboot the system + +### Manual Installation + +If you prefer manual setup: + +```bash +# Install dependencies +python3 -m pip install --upgrade pip +python3 -m pip install -r requirements.txt +python3 -m pip install --upgrade Pillow + +# Set up auto-start (optional) +sudo mkdir -p /home/pi/.config/autostart +sudo cp pidslm.desktop /home/pi/.config/autostart/ + +# Configure camera (if needed) +sudo nano /boot/config.txt +# Add: start_x=1 and gpu_mem=128 +``` -Designed using -- [OnShape](https://bit.ly/raspi-onshape) +## Usage -If you found this useful, please donate what you think it is worth to my [paypal.me](https://paypal.me/nickengman). Help cover the time of design. +### Starting the Application -Thanks, Enjoy! +Run the main application: +```bash +python3 pidslm.py +``` -# Installation +The application starts in fullscreen mode with the following controls: -For the codebase, I built the piDSLM codebase off of a forked a copy of fellow DIYer Martin Manders [MerlinPi project](https://github.com/MisterEmm/MerlinPi). The piDSLM codebase is still in its infancy but it allows the user to take photos/videos, and view them in a gallery. It also allows users to bulk upload the footage to Dropbox. To begin ssh into the Raspberry Pi and run the following command: +| Button | Function | +|--------|----------| +| **Focus** | 15-second preview mode | +| **Gallery** | Browse captured photos | +| **HD 30s** | Record 30-second video | +| **Burst** | Capture up to 10,000ms of rapid photos | +| **1h 60pix** | Timelapse: 1 hour, 60-second intervals | +| **HD 30m** | Split 30-minute video into 5-second segments | +| **Upload** | Bulk upload to Dropbox | +| **Clear** | Delete all files from Downloads | -``` +### GPIO Button Capture -git clone https://github.com/NickEngmann/pidslm.git -cd pidslm -``` +The application monitors GPIO pin 16. Pressing the connected button will: +- Trigger a photo capture (3.5-second exposure) +- Save to `/home/pi/Downloads/` with timestamp filename -You're then going to retrieve a Dropbox Access token to enable to Dropbox footage upload feature. To do this go ahead and [go to the Application Developer page on Dropbox](https://www.dropbox.com/developers/apps). Create an application and click the Generate Access Token button to generate your access token. +### Dropbox Upload -Then replace the dummy access token in Dropbox_upload.py with your new access token. +When you click the **Upload** button: +1. The app creates a busy indicator +2. `dropbox_upload.py` runs in background +3. Files in `/home/pi/Downloads/` are uploaded to Dropbox +4. Dot files, temp files, and generated files are automatically skipped -``` +### Gallery Navigation -# OAuth2 access token. TODO: login etc. -TOKEN = 'YOUR_ACCESS_TOKEN' -``` +In gallery mode: +- Use left/right arrow buttons to navigate photos +- Thumbnails show captured `.jpg` files from Downloads folder +- Press Escape or close window to return to main menu -Finally, run the INSTALL.sh script using the following command +## File Structure ``` -sudo ./INSTALL.sh +piDSLM/ +├── pidslm.py # Main GUI application +├── dropbox_upload.py # Dropbox sync utility +├── INSTALL.sh # Installation script +├── pidslm.desktop # Auto-start configuration +├── PiDSLR.fzz # 3D enclosure design (Fusion 360) +├── icon/ # UI icons (14 images) +└── tests/ # Test suite + ├── test_example.py # Example tests + ├── conftest.py # Test configuration with mocks + └── embedded_mocks.py # Hardware simulation mocks ``` +## Testing + +The project includes a test suite with hardware mocking: + +```bash +pytest tests/ -v +``` + +### Test Infrastructure + +- **RPi.GPIO mocked**: Simulates GPIO pin control +- **I2C/SPI/UART mocked**: Hardware communication simulation +- **guizero mocked**: GUI framework simulation +- **Source loading**: Custom fixture loads and tests actual source files + +### Running Tests + +Tests run successfully in both hardware and mocked environments. The mock system provides 15+ hardware module simulations for safe testing. + +## Dependencies + +- **Python 3.12+** +- **Pillow**: Image processing +- **guizero**: GUI framework +- **dropbox**: Dropbox API v2 SDK +- **RPi.GPIO**: Hardware control (production only) +- **raspistill/raspivid**: Camera utilities (production only) + +## Troubleshooting + +### Camera not working + +1. Check `/boot/config.txt` has `start_x=1` and `gpu_mem=128` +2. Verify camera is connected to CSI port 0 +3. Run `vcgencmd get_camera` to check detection + +### GPIO button not responding + +1. Verify pin 16 wiring (BCM numbering) +2. Check for button bounce issues (currently set to 2500ms) +3. Ensure RPi.GPIO is not being used elsewhere + +### Dropbox upload fails + +1. Verify TOKEN is set in `dropbox_upload.py` +2. Check internet connection +3. Review Dropbox app permissions + +### Gallery not showing photos + +1. Ensure photos are saved to `/home/pi/Downloads/` +2. Check file permissions on Downloads directory +3. Verify photo format is `.jpg` + +## Design Notes + +The piDSLM project is built as a fork of the [MerlinPi project](https://github.com/MisterEmm/MerlinPi) by Martin Manders. Key differences: + +- Custom enclosure design (3D printable) +- MHS35-TFT display integration +- Enhanced GUI layout +- Bulk Dropbox upload feature +- GPIO button capture support + +## License + +This project is built on the MerlinPi foundation. Please respect the original project license and any third-party dependencies. + +## Support + +If you found this project useful, consider supporting the design work through [PayPal](https://paypal.me/nickengman). + +For questions or contributions, please reach out via the [GitHub repository](https://github.com/NickEngmann/piDSLM). +--- +*Designed for Raspberry Pi enthusiasts and DIY camera projects.* diff --git a/dropbox_upload.py b/dropbox_upload.py index ba707f5..e63146f 100755 --- a/dropbox_upload.py +++ b/dropbox_upload.py @@ -4,37 +4,98 @@ from __future__ import print_function -import argparse import contextlib import datetime import os -import six import sys import time import unicodedata +import argparse if sys.version.startswith('2'): - input = raw_input # noqa: E501,F821; pylint: disable=redefined-builtin,undefined-variable,useless-suppression + input = raw_input import dropbox # OAuth2 access token. TODO: login etc. TOKEN = 'YOUR_ACCESS_TOKEN' -parser = argparse.ArgumentParser(description='Sync ~/Downloads to Dropbox') -parser.add_argument('folder', nargs='?', default='Downloads', - help='Folder name in your Dropbox') -parser.add_argument('rootdir', nargs='?', default='~/Downloads', - help='Local directory to upload') -parser.add_argument('--token', default=TOKEN, - help='Access token ' - '(see https://www.dropbox.com/developers/apps)') -parser.add_argument('--yes', '-y', action='store_true', - help='Answer yes to all questions') -parser.add_argument('--no', '-n', action='store_true', - help='Answer no to all questions') -parser.add_argument('--default', '-d', action='store_true', - help='Take default answer on all questions') + +def parse_args(args=None): + """Parse command line arguments for Dropbox upload. + + Args: + args: List of arguments to parse (defaults to sys.argv[1:]) + + Returns: + argparse.Namespace with parsed arguments + """ + parser = argparse.ArgumentParser( + description='Sync ~/Downloads to Dropbox', + prog='dropbox_upload.py' + ) + parser.add_argument('folder', nargs='?', default='Downloads', + help='Folder name in your Dropbox') + parser.add_argument('rootdir', nargs='?', default='~/Downloads', + help='Local directory to upload') + parser.add_argument('--token', default=TOKEN, + help='Access token (see https://www.dropbox.com/developers/apps)') + parser.add_argument('--yes', '-y', action='store_true', + help='Answer yes to all questions') + parser.add_argument('--no', '-n', action='store_true', + help='Answer no to all questions') + parser.add_argument('--default', '-d', action='store_true', + help='Take default answer on all questions') + parser.add_argument('--count', type=int, default=None, + help='Limit number of files to upload') + return parser.parse_args(args) + + +def should_skip_file(filename): + """Determine if a file should be skipped based on its name. + + Skips: + - Dot files (starting with .) + - Temporary files (ending with .tmp, .temp, starting with @ or ending with ~) + - Generated files (ending with .pyc or .pyo) + - Empty or None filenames + - __pycache__ directories + + Args: + filename: The name of the file to check + + Returns: + bool: True if file should be skipped, False otherwise + """ + if not filename: + return True + + name = filename if isinstance(filename, str) else str(filename) + + # Skip empty names + if not name: + return True + + # Skip dot files (hidden files) + if name.startswith('.'): + return True + + # Skip temporary files (Mac OS and various temp extensions) + if name.startswith('@') or name.startswith('~') or name.endswith('~'): + return True + if name.endswith('.tmp') or name.endswith('.temp'): + return True + + # Skip generated Python files + if name.endswith('.pyc') or name.endswith('.pyo'): + return True + + # Skip __pycache__ directories + if name == '__pycache__': + return True + + return False + def main(): """Main program. @@ -43,7 +104,7 @@ def main(): directories, and avoids duplicate uploads by comparing size and mtime with the server. """ - args = parser.parse_args() + args = parse_args() if sum([bool(b) for b in (args.yes, args.no, args.default)]) > 1: print('At most one of --yes, --no, --default is allowed') sys.exit(2) @@ -64,6 +125,7 @@ def main(): dbx = dropbox.Dropbox(args.token) + total_files = 0 for dn, dirs, files in os.walk(rootdir): subfolder = dn[len(rootdir):].strip(os.path.sep) listing = list_folder(dbx, folder, subfolder) @@ -75,13 +137,13 @@ def main(): if not isinstance(name, six.text_type): name = name.decode('utf-8') nname = unicodedata.normalize('NFC', name) - if name.startswith('.'): - print('Skipping dot file:', name) - elif name.startswith('@') or name.endswith('~'): - print('Skipping temporary file:', name) - elif name.endswith('.pyc') or name.endswith('.pyo'): - print('Skipping generated file:', name) - elif nname in listing: + + # Use the new should_skip_file helper + if should_skip_file(name): + print('Skipping file:', name) + continue + + if nname in listing: md = listing[nname] mtime = os.path.getmtime(fullname) mtime_dt = datetime.datetime(*time.gmtime(mtime)[:6]) @@ -103,16 +165,22 @@ def main(): overwrite=True) elif yesno('Upload %s' % name, True, args): upload(dbx, fullname, folder, subfolder, name) + + # Track total files and apply --count limit + total_files += 1 + if args.count and total_files >= args.count: + break + + if args.count and total_files >= args.count: + break # Then choose which subdirectories to traverse. keep = [] for name in dirs: - if name.startswith('.'): - print('Skipping dot directory:', name) - elif name.startswith('@') or name.endswith('~'): - print('Skipping temporary directory:', name) - elif name == '__pycache__': - print('Skipping generated directory:', name) + # Use the new should_skip_file helper for directories too + if should_skip_file(name): + print('Skipping directory:', name) + continue elif yesno('Descend into %s' % name, True, args): print('Keeping directory:', name) keep.append(name) diff --git a/icon/100black.png b/icon/100black.png old mode 100644 new mode 100755 diff --git a/icon/100trans.png b/icon/100trans.png old mode 100644 new mode 100755 diff --git a/icon/cam.png b/icon/cam.png old mode 100644 new mode 100755 diff --git a/icon/del.png b/icon/del.png old mode 100644 new mode 100755 diff --git a/icon/drop.png b/icon/drop.png old mode 100644 new mode 100755 diff --git a/icon/gallery.png b/icon/gallery.png old mode 100644 new mode 100755 diff --git a/icon/lapse.png b/icon/lapse.png old mode 100644 new mode 100755 diff --git a/icon/left.png b/icon/left.png old mode 100644 new mode 100755 diff --git a/icon/long.png b/icon/long.png old mode 100644 new mode 100755 diff --git a/icon/prev.png b/icon/prev.png old mode 100644 new mode 100755 diff --git a/icon/right.png b/icon/right.png old mode 100644 new mode 100755 diff --git a/icon/self.png b/icon/self.png old mode 100644 new mode 100755 diff --git a/icon/vid.png b/icon/vid.png old mode 100644 new mode 100755 diff --git a/pidslm.desktop b/pidslm.desktop old mode 100644 new mode 100755 diff --git a/requirements.txt b/requirements.txt old mode 100644 new mode 100755 diff --git a/tests/conftest.py b/tests/conftest.py old mode 100644 new mode 100755 index 62c73b4..5be847f --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,7 +23,7 @@ 'w1thermsensor', 'Adafruit_DHT', 'RPIO', 'pigpio', 'wiringpi', 'sense_hat', 'luma.core', 'luma.oled', 'luma.led_matrix', - 'serial', + 'serial', 'dropbox', 'dropbox.files', 'dropbox.exceptions', ] for _mod in _RPI_MODULES: diff --git a/tests/embedded_mocks.py b/tests/embedded_mocks.py old mode 100644 new mode 100755 diff --git a/tests/test_dropbox_filters.py b/tests/test_dropbox_filters.py new file mode 100644 index 0000000..b81f83d --- /dev/null +++ b/tests/test_dropbox_filters.py @@ -0,0 +1,96 @@ +"""Test dropbox_upload.py file filtering logic.""" +import pytest + + +def test_should_skip_file_dot_files(source_module): + """Test that dot files are skipped.""" + assert source_module.should_skip_file('.hidden_file') is True + assert source_module.should_skip_file('.gitignore') is True + assert source_module.should_skip_file('.DS_Store') is True + + +def test_should_skip_file_temp_files(source_module): + """Test that temporary files are skipped.""" + # Skip files ending with .tmp or .temp + assert source_module.should_skip_file('file.tmp') is True + assert source_module.should_skip_file('file.temp') is True + # Skip Mac OS temporary files (starting with ~) + assert source_module.should_skip_file('~backup') is True + assert source_module.should_skip_file('@recycled') is True + + +def test_should_skip_file_generated_files(source_module): + """Test that generated files are skipped.""" + assert source_module.should_skip_file('file.pyo') is True + assert source_module.should_skip_file('module.pyc') is True + assert source_module.should_skip_file('script.pyo') is True + + +def test_should_skip_file_pycache_dir(source_module): + """Test that __pycache__ directories are skipped.""" + assert source_module.should_skip_file('__pycache__') is True + assert source_module.should_skip_file('__pycache__/module.pyc') is True + + +def test_should_skip_file_empty_string(source_module): + """Test that empty filenames are skipped.""" + assert source_module.should_skip_file('') is True + + +def test_should_skip_file_none(source_module): + """Test that None filenames are skipped.""" + assert source_module.should_skip_file(None) is True + + +def test_should_not_skip_normal_files(source_module): + """Test that normal files are not skipped.""" + assert source_module.should_skip_file('photo.jpg') is False + assert source_module.should_skip_file('image.png') is False + assert source_module.should_skip_file('document.pdf') is False + assert source_module.should_skip_file('video.mp4') is False + + +def test_should_skip_file_case_sensitive(source_module): + """Test that filtering is case sensitive.""" + # These should NOT be skipped (different case) + assert source_module.should_skip_file('file.JPG') is False + assert source_module.should_skip_file('FILE.pyc') is True # lowercase check + + +def test_parse_args_basic(source_module): + """Test basic argument parsing.""" + args = source_module.parse_args(['folder', 'rootdir']) + assert args.folder == 'folder' + assert args.rootdir == 'rootdir' + + +def test_parse_args_with_count(source_module): + """Test --count argument parsing.""" + args = source_module.parse_args(['folder', 'rootdir', '--count', '5']) + assert args.folder == 'folder' + assert args.rootdir == 'rootdir' + assert args.count == 5 + + +def test_parse_args_yes_no_flags(source_module): + """Test --yes and --no flags.""" + args = source_module.parse_args(['folder', 'rootdir', '--yes']) + assert args.yes is True + assert args.no is False + + args = source_module.parse_args(['folder', 'rootdir', '--no']) + assert args.no is True + assert args.yes is False + + +def test_parse_args_default_folder(source_module): + """Test default folder argument.""" + args = source_module.parse_args(['rootdir']) + assert args.folder == 'rootdir' + + +def test_parse_args_default_rootdir(source_module): + """Test default rootdir argument.""" + args = source_module.parse_args(['folder']) + assert args.folder == 'folder' + assert args.rootdir == '~/Downloads'