Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Release

on:
release:
types:
- published

permissions:
contents: write

jobs:
release:
name: Prepare release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Create release archive
run: |
cd custom_components/pytap
zip -r ../../pytap.zip . -x '__pycache__/*' '*/__pycache__/*' '.pytest_cache/*' '*/.pytest_cache/*'

- name: Upload release asset
uses: softprops/action-gh-release@v1
with:
files: ./pytap.zip
50 changes: 50 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Validate

on:
push:
branches:
- main
- dev
pull_request:
branches:
- main

jobs:
hassfest:
name: Hassfest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: home-assistant/actions/hassfest@master

hacs:
name: HACS Validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hacs/action@main
with:
category: integration
ignore: brands

lint:
name: Ruff Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: pip install ruff
- run: ruff check custom_components/pytap/

tests:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: pip install -r requirements-test.txt
- run: python -m pytest tests/ -vv --tb=short
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![HA Version](https://img.shields.io/badge/Home%20Assistant-2024.1%2B-blue.svg)](https://www.home-assistant.io/)
[![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://hacs.xyz/)
[![Validate](https://github.com/azebro/pytap/actions/workflows/validate.yml/badge.svg)](https://github.com/azebro/pytap/actions/workflows/validate.yml)

A Home Assistant custom integration for monitoring **Tigo TAP solar energy systems**. PyTap connects to your Tigo gateway over TCP, passively listens to the RS-485 bus protocol, and exposes real-time per-optimizer sensor entities — power, voltage, current, temperature, and more.

Expand Down Expand Up @@ -62,9 +64,13 @@ PyTap also creates aggregate virtual devices:
1. Copy the `custom_components/pytap` folder into your Home Assistant `config/custom_components/` directory.
2. Restart Home Assistant.

### HACS (coming soon)
### HACS (Recommended)

HACS distribution is planned for a future release.
1. Open **HACS** in your Home Assistant instance.
2. Click the **three dots** menu in the top right and select **Custom repositories**.
3. Add `https://github.com/azebro/pytap` with category **Integration**.
4. Search for **PyTap** and click **Download**.
5. Restart Home Assistant.

---

Expand Down
5 changes: 2 additions & 3 deletions custom_components/pytap/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,8 @@

import logging
import re
import uuid
from typing import Any

import voluptuous as vol
import uuid

from homeassistant.config_entries import (
ConfigEntry,
Expand All @@ -26,6 +24,7 @@
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
import voluptuous as vol

from .const import (
CONF_MODULE_BARCODE,
Expand Down
2 changes: 1 addition & 1 deletion custom_components/pytap/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
from __future__ import annotations

import asyncio
from datetime import datetime
import logging
import threading
import time
from datetime import datetime
from typing import Any

from homeassistant.config_entries import ConfigEntry
Expand Down
2 changes: 1 addition & 1 deletion custom_components/pytap/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@
"iot_class": "local_push",
"issue_tracker": "https://github.com/azebro/pytap/issues",
"requirements": [],
"version": "0.3.0"
"version": "1.0.0"
}
69 changes: 34 additions & 35 deletions custom_components/pytap/pytap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,55 +6,54 @@
__version__ = "0.1.0"

# Core types
from .core.types import (
GatewayID,
Address,
FrameType,
Frame,
NodeID,
NodeAddress,
LongAddress,
RSSI,
SlotCounter,
PacketType,
ReceivedPacketHeader,
U12Pair,
PowerReport,
GatewayInfo,
NodeInfo,
iter_received_packets,
# API functions
from .api import (
connect,
create_parser,
parse_bytes,
)

# Barcode utilities
from .core.barcode import barcode_from_address, decode_barcode, encode_barcode

# CRC
from .core.crc import crc

# Events
from .core.events import (
Event,
PowerReportEvent,
InfrastructureEvent,
TopologyEvent,
PowerReportEvent,
StringEvent,
TopologyEvent,
)

# Parser
from .core.parser import Parser

# State management
from .core.state import (
SlotClock,
NodeTableBuilder,
PersistentState,
SlotClock,
)

# Parser
from .core.parser import Parser

# Barcode utilities
from .core.barcode import encode_barcode, decode_barcode, barcode_from_address

# CRC
from .core.crc import crc

# API functions
from .api import (
create_parser,
parse_bytes,
connect,
from .core.types import (
RSSI,
Address,
Frame,
FrameType,
GatewayID,
GatewayInfo,
LongAddress,
NodeAddress,
NodeID,
NodeInfo,
PacketType,
PowerReport,
ReceivedPacketHeader,
SlotCounter,
U12Pair,
iter_received_packets,
)

__all__ = [
Expand Down
7 changes: 3 additions & 4 deletions custom_components/pytap/pytap/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,17 @@
from __future__ import annotations

import logging
from typing import Optional

from .core.parser import Parser
from .core.events import Event
from .core.parser import Parser
from .core.source import SerialSource, TcpSource
from .core.state import PersistentState
from .core.source import TcpSource, SerialSource

logger = logging.getLogger(__name__)


def create_parser(
persistent_state: Optional[PersistentState] = None,
persistent_state: PersistentState | None = None,
) -> Parser:
"""Create a new protocol parser instance.

Expand Down
6 changes: 2 additions & 4 deletions custom_components/pytap/pytap/core/barcode.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
Only addresses with prefix 04:C0:5B are barcode-eligible.
"""

from typing import Optional


# 16-character barcode alphabet (no vowels)
_ALPHABET = 'GHJKLMNPRSTVWXYZ'
Expand Down Expand Up @@ -43,7 +41,7 @@ def _compute_barcode_crc(address_bytes: bytes) -> int:
return crc


def encode_barcode(address_bytes: bytes) -> Optional[str]:
def encode_barcode(address_bytes: bytes) -> str | None:
"""Encode an 8-byte MAC address to a Tigo barcode string.

Returns None if the address doesn't have the 04:C0:5B prefix.
Expand Down Expand Up @@ -116,7 +114,7 @@ def decode_barcode(barcode: str) -> bytes:
return address


def barcode_from_address(address_bytes: bytes) -> Optional[str]:
def barcode_from_address(address_bytes: bytes) -> str | None:
"""Convenience: encode barcode from address bytes, returning None if not eligible."""
try:
return encode_barcode(address_bytes)
Expand Down
7 changes: 3 additions & 4 deletions custom_components/pytap/pytap/core/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
All events are dataclass instances with a to_dict() method for JSON serialization.
"""

from dataclasses import dataclass, asdict
from dataclasses import asdict, dataclass
from datetime import datetime
from typing import Optional


@dataclass
Expand All @@ -28,7 +27,7 @@ class PowerReportEvent(Event):

gateway_id: int
node_id: int
barcode: Optional[str]
barcode: str | None
voltage_in: float
voltage_out: float
current_in: float
Expand All @@ -43,7 +42,7 @@ def __init__(
*,
gateway_id: int,
node_id: int,
barcode: Optional[str],
barcode: str | None,
voltage_in: float,
voltage_out: float,
current_in: float,
Expand Down
29 changes: 14 additions & 15 deletions custom_components/pytap/pytap/core/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,32 @@
feed(bytes) -> list[Event] interface.
"""

import logging
import struct
from dataclasses import dataclass, asdict
from dataclasses import asdict, dataclass
from datetime import datetime
from enum import Enum, auto
from typing import Optional
import logging
import struct

from .barcode import barcode_from_address
from .crc import crc
from .events import (
Event,
PowerReportEvent,
InfrastructureEvent,
TopologyEvent,
PowerReportEvent,
StringEvent,
TopologyEvent,
)
from .state import SlotClock, NodeTableBuilder, PersistentState
from .state import NodeTableBuilder, PersistentState, SlotClock
from .types import (
Address,
FrameType,
Frame,
NodeAddress,
FrameType,
LongAddress,
SlotCounter,
NodeAddress,
PacketType,
ReceivedPacketHeader,
PowerReport,
ReceivedPacketHeader,
SlotCounter,
iter_received_packets,
)

Expand Down Expand Up @@ -131,7 +130,7 @@ class Parser:

def __init__(
self,
persistent_state: Optional[PersistentState] = None,
persistent_state: PersistentState | None = None,
):
# Frame accumulator state
self._state: _FrameState = _FrameState.IDLE
Expand All @@ -147,7 +146,7 @@ def __init__(
self._captured_slot_times: dict[int, datetime] = {}

# Enumeration state
self._enum_state: Optional[_EnumerationState] = None
self._enum_state: _EnumerationState | None = None

# Infrastructure — caller owns persistence; parser only mutates in memory
self._persistent_state: PersistentState = (
Expand Down Expand Up @@ -208,7 +207,7 @@ def counters(self) -> dict:
# Frame Accumulation State Machine
# -------------------------------------------------------------------

def _accumulate(self, byte: int) -> Optional[Frame]:
def _accumulate(self, byte: int) -> Frame | None:
"""Process a single byte. Returns a Frame when a complete valid frame is found."""
old_state = self._state

Expand Down Expand Up @@ -299,7 +298,7 @@ def _accumulate(self, byte: int) -> Optional[Frame]:
self._state = next_state
return None

def _decode_frame(self, buffer: bytearray) -> Optional[Frame]:
def _decode_frame(self, buffer: bytearray) -> Frame | None:
"""Decode a completed frame from the buffer."""
if len(buffer) < 6:
self._counters.runts += 1
Expand Down
Loading