# AtmosPyre

A simple, unified Python interface for Modbus-based environmental sensors. Write sensor drivers once, use them everywhere.

## Why AtmosPyre?

Instead of buying expensive vendor software for each sensor, **write your own sensor interface in ~100 lines of Python**. AtmosPyre provides a clean, consistent API that makes adding new sensors straightforward.

### Key Benefits

- 💰 **Save money** - No need for proprietary software licenses
- 🔧 **Easy to extend** - Add new sensors in minutes
- 🎯 **Simple interface** - One API for all sensors
- ✅ **Tested** - Comprehensive test coverage
- 📝 **Well documented** - Clear examples and patterns

## Currently Supported Sensors

- **Vaisala GMP252** - CO₂ and temperature sensor
- **RadonTech AlphaTRACER** - Radon concentration sensor

More sensors coming soon!

## Installation

```bash
# Clone the repository
git clone <repository-url>
cd atmospyre

# Install the package
pip install .

# OR install in development mode (recommended for development)
pip install -e .

# Install with development dependencies (for testing)
pip install -e ".[dev]"
```

### Requirements

- Python 3.8+
- `minimalmodbus` - For Modbus RTU communication
- `multipledispatch` - For tag-based dispatch

Development dependencies (optional):
- `pytest` - Test framework
- `pytest-mock` - Mocking for tests
- `pytest-cov` - Test coverage reports

## Quick Start

### Reading from a CO₂ Sensor (GMP252)

```python
from atmospyre.sensors.co2.vaisala.gmp252 import GMP252, CO2, TEMPERATURE

# Create sensor instance
sensor = GMP252(port='COM3', slave_address=1)

# Read CO₂ concentration
result = sensor.read(CO2)
print(f"CO₂: {result[CO2]} ppm")

# Read multiple values at once
result = sensor.read([CO2, TEMPERATURE])
print(f"CO₂: {result[CO2]} ppm")
print(f"Temperature: {result[TEMPERATURE]} °C")
```

### Reading from a Radon Sensor (AlphaTRACER)

```python
from atmospyre.sensors.radon.alphatracer import AlphaTRACER, RADON_LIVE, RADON_24H

# Create sensor instance
sensor = AlphaTRACER(port='COM4', slave_address=1)

# Read live radon concentration
result = sensor.read(RADON_LIVE)
print(f"Live radon: {result[RADON_LIVE]} Bq/m³")

# Read 24-hour average
result = sensor.read([RADON_LIVE, RADON_24H])
print(f"Live: {result[RADON_LIVE]} Bq/m³")
print(f"24h avg: {result[RADON_24H]} Bq/m³")
```

### Custom Serial Settings

All sensors support custom serial port settings for challenging installations:

```python
# For long cables or noisy environments
sensor = GMP252(
    port='COM3',
    slave_address=1,
    baudrate=9600,      # Lower baudrate for reliability
    timeout=2.0         # Longer timeout
)

# For fast polling applications
sensor = GMP252(
    port='COM3',
    slave_address=1,
    timeout=0.2         # Shorter timeout
)
```

## Adding Your Own Sensor

Adding a new sensor is straightforward. Here's the complete process:

### 1. Create Your Sensor File

```python
# atmospyre/sensors/your_category/your_sensor.py

from atmospyre.sensors.sensor import Sensor
from atmospyre.sensors.tags import ReadTag
from multipledispatch import dispatch
from minimalmodbus import Instrument, BYTEORDER_LITTLE_SWAP

# Define tags for your sensor's measurements
class HumidityTag(ReadTag):
    """Tag for humidity measurement."""
    pass

class PressureTag(ReadTag):
    """Tag for pressure measurement."""
    pass

# Create tag instances
HUMIDITY = HumidityTag()
PRESSURE = PressureTag()

# Create dispatch namespace
your_sensor_namespace = {}

# Define read functions for each tag
@dispatch(object, HumidityTag, namespace=your_sensor_namespace)
def _read(instrument: Instrument, tag: HumidityTag) -> float:
    """Read humidity from register 10."""
    return instrument.read_float(10, byteorder=BYTEORDER_LITTLE_SWAP)

@dispatch(object, PressureTag, namespace=your_sensor_namespace)
def _read(instrument: Instrument, tag: PressureTag) -> float:
    """Read pressure from register 12."""
    return instrument.read_float(12, byteorder=BYTEORDER_LITTLE_SWAP)

# Create your sensor class
class YourSensor(Sensor):
    """Your sensor description.

    Available tags:
        - HUMIDITY: Relative humidity (%)
        - PRESSURE: Atmospheric pressure (hPa)
    """

    def __init__(
        self,
        port: str,
        slave_address: int = 1,
        baudrate: int = 9600,
        stopbits: int = 1,
        bytesize: int = 8,
        parity: str = 'N',
        timeout: float = 0.5
    ):
        """Initialize sensor."""
        super().__init__(
            port=port,
            valid_tags=[HUMIDITY, PRESSURE],
            namespace=your_sensor_namespace,
            slave_address=slave_address,
            baudrate=baudrate,
            stopbits=stopbits,
            bytesize=bytesize,
            parity=parity,
            timeout=timeout
        )
```

### 2. Write Tests (Optional but Recommended)

```python
# tests/test_your_sensor.py

import pytest
from atmospyre.sensors.your_category.your_sensor import YourSensor, HUMIDITY, PRESSURE

class TestYourSensorConstructor:
    """Test constructor."""

    def test_constructor_with_defaults(self, mock_instrument):
        sensor = YourSensor(port='COM3', slave_address=1)
        assert sensor.instrument.serial.baudrate == 9600

class TestYourSensorRead:
    """Test reading values."""

    def test_read_humidity(self, mock_instrument):
        mock_instrument.read_float.return_value = 45.5

        sensor = YourSensor(port='COM3', slave_address=1)
        result = sensor.read(HUMIDITY)

        assert result[HUMIDITY] == 45.5
```

### 3. Use Your Sensor

```python
from atmospyre.sensors.your_category.your_sensor import YourSensor, HUMIDITY, PRESSURE

sensor = YourSensor(port='COM3', slave_address=1)
result = sensor.read([HUMIDITY, PRESSURE])

print(f"Humidity: {result[HUMIDITY]} %")
print(f"Pressure: {result[PRESSURE]} hPa")
```

That's it! Your sensor works exactly like the built-in ones.

## Finding Register Addresses

You'll need your sensor's Modbus register map from the manual. Look for:

- **Register address** - Where the value is stored (e.g., 0, 256, 2048)
- **Data type** - Float (32-bit), Integer (16-bit), or Long (32-bit)
- **Byte order** - Big-endian or little-endian (usually specified in manual)

### Common Modbus Read Functions

```python
# For 16-bit integers (most common)
value = instrument.read_register(256)

# For 32-bit floats
value = instrument.read_float(0, byteorder=BYTEORDER_LITTLE_SWAP)

# For 32-bit integers
value = instrument.read_long(20, byteorder=BYTEORDER_BIG)
```

## Project Structure

```
atmospyre/
├── sensors/
│   ├── sensor.py              # Base Sensor class
│   ├── tags.py                # Tag base class
│   ├── co2/
│   │   └── vaisala/
│   │       └── gmp252.py      # GMP252 implementation
│   └── radon/
│       └── alphatracer.py     # AlphaTRACER implementation
└── tests/
    ├── conftest.py            # Shared test fixtures
    ├── test_gmp252.py         # GMP252 tests
    └── test_alphatracer.py    # AlphaTRACER tests
```

## Testing

The project uses pytest for testing. All sensors have comprehensive test coverage.

### Running Tests

```bash
# Run all tests
pytest

# Run tests for a specific sensor
pytest tests/test_gmp252.py

# Run with verbose output
pytest -v

# Run with coverage
pytest --cov=atmospyre
```

### Test Fixtures (conftest.py)

Tests use a global `mock_instrument` fixture that automatically mocks Modbus communication:

```python
# tests/conftest.py

import pytest

@pytest.fixture
def mock_instrument(mocker):
    """Mock minimalmodbus.Instrument globally."""
    mock_class = mocker.patch('minimalmodbus.Instrument')
    mock_instance = mocker.Mock()
    mock_class.return_value = mock_instance

    # Configure default properties
    mock_instance.serial.baudrate = None
    mock_instance.serial.stopbits = None
    mock_instance.serial.bytesize = None
    mock_instance.serial.parity = None
    mock_instance.serial.timeout = None
    mock_instance.mode = None
    mock_instance.close_port_after_each_call = None

    return mock_instance
```

This fixture is automatically available to all test files.


## Roadmap

### Planned Features

- [ ] More sensor drivers (humidity, pressure, particle counters)
- [ ] Modbus TCP support (for network-connected sensors)
- [ ] Modbus controller support (write operations)
- [ ] Data logging utilities
- [ ] Automatic sensor discovery
- [ ] Configuration file support
- [ ] Web dashboard for monitoring

### Contributing

Want to add your own sensor? Great! Follow these steps:

1. Look at existing sensor implementations (GMP252 or AlphaTRACER)
2. Copy the structure for your sensor
3. Write simple tests (see test files for examples)
4. Test with real hardware if possible
5. Submit a pull request

We welcome contributions, especially from those adding new sensors!

## Support

- **Documentation**: Check the docstrings in the code
- **Examples**: See the `examples/` directory (coming soon)
- **Issues**: Report bugs or request features on GitHub
- **Questions**: Open a discussion on GitHub

## License

[Add your license information here]

## Acknowledgments

- Built with [MinimalModbus](https://minimalmodbus.readthedocs.io/) for Modbus communication
- Inspired by the need for cost-effective sensor integration
- Thanks to all contributors!

---