updates
This commit is contained in:
parent
86288d9a74
commit
13a3f2e24f
25 changed files with 1724 additions and 1996 deletions
|
|
@ -1,133 +0,0 @@
|
|||
# Distroless Docker Options for APRS.me
|
||||
|
||||
This document compares different distroless approaches for containerizing the APRS.me Elixir/Phoenix application.
|
||||
|
||||
## Current Setup vs Distroless Options
|
||||
|
||||
### Current Setup (`Dockerfile`)
|
||||
- **Base Image**: `debian:bullseye-slim` (~80MB)
|
||||
- **Security**: Good (non-root user, hardened)
|
||||
- **Attack Surface**: Moderate (full Debian with package manager)
|
||||
- **Debugging**: Easy (shell access, package manager)
|
||||
- **Compatibility**: Excellent
|
||||
|
||||
### Option 1: Simple Distroless (`Dockerfile.distroless-simple`)
|
||||
- **Base Image**: `gcr.io/distroless/cc-debian11:nonroot` (~20MB)
|
||||
- **Security**: Excellent (no shell, no package manager)
|
||||
- **Attack Surface**: Minimal
|
||||
- **Debugging**: Difficult (no shell access)
|
||||
- **Compatibility**: Good for most Elixir applications
|
||||
- **Recommended**: ✅ **BEST STARTING POINT**
|
||||
|
||||
### Option 2: Custom Library Distroless (`Dockerfile.distroless`)
|
||||
- **Base Image**: `gcr.io/distroless/base-debian11:nonroot` (~15MB)
|
||||
- **Security**: Excellent
|
||||
- **Attack Surface**: Minimal
|
||||
- **Debugging**: Very difficult
|
||||
- **Compatibility**: May require fine-tuning
|
||||
- **Complexity**: High (manual library management)
|
||||
|
||||
### Option 3: Static Distroless (`Dockerfile.distroless-static`)
|
||||
- **Base Image**: `gcr.io/distroless/static-debian11:nonroot` (~2MB)
|
||||
- **Security**: Maximum
|
||||
- **Attack Surface**: Absolute minimum
|
||||
- **Debugging**: Nearly impossible
|
||||
- **Compatibility**: **May not work** with Erlang/OTP
|
||||
- **Recommended**: ❌ **NOT RECOMMENDED** for Elixir
|
||||
|
||||
## Recommended Migration Path
|
||||
|
||||
### Phase 1: Test Simple Distroless
|
||||
1. Use `Dockerfile.distroless-simple`
|
||||
2. Test all application functionality
|
||||
3. Monitor for any runtime issues
|
||||
4. Verify external dependencies work (database, APIs)
|
||||
|
||||
### Phase 2: Optimize (if needed)
|
||||
1. If Phase 1 works perfectly, you're done!
|
||||
2. If you need smaller images, consider custom library approach
|
||||
3. Profile which libraries are actually needed
|
||||
|
||||
## Key Benefits of Distroless
|
||||
|
||||
### Security Improvements
|
||||
- **No shell access**: Eliminates shell-based attacks
|
||||
- **No package manager**: Cannot install malicious packages
|
||||
- **Minimal attack surface**: Only your application + runtime
|
||||
- **Reduced CVEs**: Fewer components = fewer vulnerabilities
|
||||
- **Immutable**: Cannot be modified at runtime
|
||||
|
||||
### Operational Benefits
|
||||
- **Smaller images**: Faster pulls, less storage
|
||||
- **Faster startup**: Less to initialize
|
||||
- **Better compliance**: Meets strict security requirements
|
||||
- **Supply chain security**: Google-maintained base images
|
||||
|
||||
## Potential Challenges
|
||||
|
||||
### Debugging Difficulties
|
||||
```bash
|
||||
# This won't work in distroless:
|
||||
docker exec -it container /bin/bash
|
||||
|
||||
# Use this instead:
|
||||
docker run --rm -it --entrypoint="" your-image:tag /app/bin/server remote
|
||||
```
|
||||
|
||||
### Limited Troubleshooting
|
||||
- No shell for investigating issues
|
||||
- Must rely on application logs
|
||||
- Consider adding detailed logging/metrics
|
||||
|
||||
### Dependency Issues
|
||||
- Some NIFs might need additional libraries
|
||||
- SSL/TLS certificates need careful handling
|
||||
- Time zones might not be available
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
Before switching to distroless, verify:
|
||||
|
||||
- [ ] Application starts successfully
|
||||
- [ ] Database connections work
|
||||
- [ ] Phoenix LiveView functions
|
||||
- [ ] Asset serving works
|
||||
- [ ] SSL/HTTPS connections work
|
||||
- [ ] External API calls succeed
|
||||
- [ ] WebSocket connections work
|
||||
- [ ] Health checks pass
|
||||
- [ ] Graceful shutdown works
|
||||
- [ ] Logging outputs correctly
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Test simple distroless version
|
||||
docker build -f Dockerfile.distroless-simple -t aprs:distroless-simple .
|
||||
|
||||
# Compare image sizes
|
||||
docker images | grep aprs
|
||||
|
||||
# Test functionality
|
||||
docker run -p 4000:4000 aprs:distroless-simple
|
||||
```
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If distroless causes issues:
|
||||
1. Keep current `Dockerfile` as `Dockerfile.debian`
|
||||
2. Switch CI/CD back to Debian version
|
||||
3. Investigate specific compatibility issues
|
||||
4. Gradually migrate features
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Recommendation**: Start with `Dockerfile.distroless-simple`
|
||||
|
||||
This provides:
|
||||
- Significant security improvements
|
||||
- Manageable complexity
|
||||
- Good compatibility with Elixir/Phoenix
|
||||
- Easy rollback if needed
|
||||
|
||||
The `gcr.io/distroless/cc-debian11:nonroot` image includes the C runtime libraries that Erlang/OTP requires, making it the most compatible distroless option for Elixir applications.
|
||||
141
Dockerfile-bak
141
Dockerfile-bak
|
|
@ -1,141 +0,0 @@
|
|||
ARG ELIXIR_VERSION=1.18.4
|
||||
ARG OTP_VERSION=27.2.4
|
||||
ARG DEBIAN_VERSION=bullseye-20250520-slim
|
||||
|
||||
# Set default non-root user and group IDs
|
||||
ARG USER_ID=1000
|
||||
ARG GROUP_ID=1000
|
||||
|
||||
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
|
||||
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
|
||||
|
||||
FROM ${BUILDER_IMAGE} AS builder
|
||||
|
||||
# install build dependencies
|
||||
RUN apt-get update -y && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install -y build-essential git && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# prepare build dir
|
||||
WORKDIR /app
|
||||
|
||||
# install hex + rebar
|
||||
RUN mix local.hex --force && \
|
||||
mix local.rebar --force
|
||||
|
||||
# set build ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
||||
# install mix dependencies
|
||||
COPY mix.exs mix.lock ./
|
||||
RUN mix deps.get --only $MIX_ENV
|
||||
RUN mkdir config
|
||||
|
||||
# copy compile-time config files before we compile dependencies
|
||||
# to ensure any relevant config change will trigger the dependencies
|
||||
# to be re-compiled.
|
||||
COPY config/config.exs config/${MIX_ENV}.exs config/
|
||||
RUN mix deps.compile
|
||||
|
||||
COPY priv priv
|
||||
|
||||
COPY lib lib
|
||||
|
||||
COPY assets assets
|
||||
|
||||
# compile assets
|
||||
RUN mix assets.deploy
|
||||
|
||||
# Compile the release
|
||||
RUN mix compile
|
||||
|
||||
# Changes to config/runtime.exs don't require recompiling the code
|
||||
COPY config/runtime.exs config/
|
||||
|
||||
COPY rel rel
|
||||
RUN mix release
|
||||
|
||||
# start a new build stage so that the final image will only contain
|
||||
# the compiled release and other runtime necessities
|
||||
FROM ${RUNNER_IMAGE}
|
||||
|
||||
# Install security updates and required packages
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update -y && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libstdc++6 \
|
||||
openssl \
|
||||
libncurses5 \
|
||||
locales \
|
||||
ca-certificates \
|
||||
tini && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
# Create a non-root user and group with specific ID
|
||||
groupadd -g 1000 aprs && \
|
||||
useradd -r -g aprs -u 1000 -s /bin/false -M aprs && \
|
||||
# Remove setuid and setgid permissions
|
||||
find / -perm /6000 -type f -exec chmod a-s {} \; || true && \
|
||||
# Secure system configurations
|
||||
chmod 0600 /etc/login.defs && \
|
||||
chmod 0600 /etc/passwd && \
|
||||
chmod 0600 /etc/group && \
|
||||
# Create and secure app directory
|
||||
mkdir -p /app && \
|
||||
chown aprs:aprs /app && \
|
||||
chmod 0750 /app
|
||||
|
||||
# Set the locale
|
||||
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||
|
||||
ENV LANG en_US.UTF-8
|
||||
ENV LANGUAGE en_US:en
|
||||
ENV LC_ALL en_US.UTF-8
|
||||
|
||||
WORKDIR "/app"
|
||||
|
||||
# Set security-related environment variables
|
||||
ENV MIX_ENV="prod" \
|
||||
LANG=en_US.UTF-8 \
|
||||
LANGUAGE=en_US:en \
|
||||
LC_ALL=en_US.UTF-8 \
|
||||
# Disable history files
|
||||
HISTFILE=/dev/null \
|
||||
# Prevent writing .erlang.cookie file
|
||||
HOME=/dev/null \
|
||||
# Add security headers
|
||||
SECURITY_HEADERS="true" \
|
||||
# Disable debug info in production
|
||||
ERL_AFLAGS="+S 1:1 +A 1 +K true -kernel shell_history enabled -kernel shell_history_file_bytes 0" \
|
||||
PHX_SERVER=true
|
||||
|
||||
# Only copy the final release from the build stage
|
||||
COPY --from=builder --chown=1000:1000 /app/_build/${MIX_ENV}/rel/aprs ./
|
||||
|
||||
USER 1000
|
||||
|
||||
# Ensure the server binary is executable
|
||||
RUN chmod +x /app/bin/server
|
||||
|
||||
# If using an environment that doesn't automatically reap zombie processes, it is
|
||||
# advised to add an init process such as tini via `apt-get install`
|
||||
# above and adding an entrypoint. See https://github.com/krallin/tini for details
|
||||
# ENTRYPOINT ["/tini", "--"]
|
||||
|
||||
CMD /app/bin/server
|
||||
|
||||
# Add security-related metadata
|
||||
LABEL org.opencontainers.image.vendor="APRS.me" \
|
||||
org.opencontainers.image.title="APRS.me Server" \
|
||||
org.opencontainers.image.description="APRS.me server with security hardening" \
|
||||
org.opencontainers.image.version="${MIX_ENV}" \
|
||||
org.opencontainers.image.created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
|
||||
security.root-user="false" \
|
||||
security.non-root-user="app" \
|
||||
security.privileged="false"
|
||||
|
||||
# Container configuration is handled by Dokku
|
||||
EXPOSE $PORT
|
||||
154
Dockerfile-ugh
154
Dockerfile-ugh
|
|
@ -1,154 +0,0 @@
|
|||
ARG ELIXIR_VERSION=1.18.4
|
||||
ARG OTP_VERSION=27.2.4
|
||||
ARG DEBIAN_VERSION=bullseye-20250520-slim
|
||||
|
||||
# Set default non-root user and group IDs
|
||||
ARG USER_ID=1000
|
||||
ARG GROUP_ID=1000
|
||||
|
||||
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
|
||||
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
|
||||
|
||||
FROM ${BUILDER_IMAGE} AS builder
|
||||
|
||||
# Set non-interactive frontend for debconf
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# install build dependencies
|
||||
RUN apt-get update -y && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
git \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /tmp/* \
|
||||
&& rm -rf /var/tmp/*
|
||||
|
||||
# prepare build dir
|
||||
WORKDIR /app
|
||||
|
||||
# install hex + rebar
|
||||
RUN mix local.hex --force && \
|
||||
mix local.rebar --force
|
||||
|
||||
# set build ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
||||
# install mix dependencies
|
||||
COPY mix.exs mix.lock ./
|
||||
RUN mix deps.get --only $MIX_ENV
|
||||
RUN mkdir config
|
||||
|
||||
# copy compile-time config files before we compile dependencies
|
||||
# to ensure any relevant config change will trigger the dependencies
|
||||
# to be re-compiled.
|
||||
COPY config/config.exs config/${MIX_ENV}.exs config/
|
||||
RUN mix deps.compile
|
||||
|
||||
COPY priv priv
|
||||
|
||||
COPY lib lib
|
||||
|
||||
COPY assets assets
|
||||
|
||||
# compile assets
|
||||
RUN mix assets.deploy
|
||||
|
||||
# Compile the release
|
||||
RUN mix compile
|
||||
|
||||
# Changes to config/runtime.exs don't require recompiling the code
|
||||
COPY config/runtime.exs config/
|
||||
|
||||
COPY rel rel
|
||||
RUN mix release
|
||||
|
||||
# start a new build stage so that the final image will only contain
|
||||
# the compiled release and other runtime necessities
|
||||
FROM ${RUNNER_IMAGE}
|
||||
|
||||
# Install security updates and required packages
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update -y && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libstdc++6 \
|
||||
openssl \
|
||||
libncurses5 \
|
||||
locales \
|
||||
ca-certificates \
|
||||
tini && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
# Create a non-root user and group with specific ID
|
||||
groupadd -g 1000 aprs && \
|
||||
useradd -r -g aprs -u 1000 -s /bin/false -M aprs && \
|
||||
# Remove setuid and setgid permissions
|
||||
find / -perm /6000 -type f -exec chmod a-s {} \; || true && \
|
||||
# Secure system configurations
|
||||
chmod 0600 /etc/login.defs && \
|
||||
chmod 0600 /etc/passwd && \
|
||||
chmod 0600 /etc/group && \
|
||||
# Create and secure app directory
|
||||
mkdir -p /app && \
|
||||
chown aprs:aprs /app && \
|
||||
chmod 0750 /app
|
||||
|
||||
# Set the locale
|
||||
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV LANGUAGE=en_US:en
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
|
||||
WORKDIR "/app"
|
||||
|
||||
# Set security-related environment variables
|
||||
ENV MIX_ENV="prod" \
|
||||
LANG=en_US.UTF-8 \
|
||||
LANGUAGE=en_US:en \
|
||||
LC_ALL=en_US.UTF-8 \
|
||||
# Disable history files
|
||||
HISTFILE=/dev/null \
|
||||
# Prevent writing .erlang.cookie file
|
||||
HOME=/dev/null \
|
||||
# Add security headers
|
||||
SECURITY_HEADERS="true" \
|
||||
# Disable debug info in production
|
||||
ERL_AFLAGS="+S 1:1 +A 1 +K true -kernel shell_history enabled -kernel shell_history_file_bytes 0" \
|
||||
PHX_SERVER=true
|
||||
|
||||
# Only copy the final release from the build stage
|
||||
COPY --from=builder --chown=1000:1000 /app/_build/${MIX_ENV}/rel/aprs ./
|
||||
|
||||
# Copy the debug startup script
|
||||
COPY --chown=1000:1000 start_server.sh ./
|
||||
|
||||
USER 1000
|
||||
|
||||
# Ensure the server binary and startup script are executable
|
||||
RUN chmod +x /app/bin/server && \
|
||||
chmod +x /app/start_server.sh
|
||||
|
||||
# If using an environment that doesn't automatically reap zombie processes, it is
|
||||
# advised to add an init process such as tini via `apt-get install`
|
||||
# above and adding an entrypoint. See https://github.com/krallin/tini for details
|
||||
# ENTRYPOINT ["/tini", "--"]
|
||||
|
||||
# Add specific capabilities needed by the app
|
||||
CMD ["/app/start_server.sh"]
|
||||
|
||||
# Add security-related metadata
|
||||
LABEL org.opencontainers.image.vendor="APRS.me" \
|
||||
org.opencontainers.image.title="APRS.me Server" \
|
||||
org.opencontainers.image.description="APRS.me server with security hardening" \
|
||||
org.opencontainers.image.version="${MIX_ENV}" \
|
||||
org.opencontainers.image.created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
|
||||
security.root-user="false" \
|
||||
security.non-root-user="app" \
|
||||
security.privileged="false"
|
||||
|
||||
# Container configuration is handled by Dokku
|
||||
# Port is set dynamically by Dokku via PORT environment variable
|
||||
EXPOSE 5000
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
ARG ELIXIR_VERSION=1.18.4
|
||||
ARG OTP_VERSION=27.2.4
|
||||
ARG DEBIAN_VERSION=bullseye-20250520-slim
|
||||
|
||||
# Set default non-root user and group IDs
|
||||
ARG USER_ID=1000
|
||||
ARG GROUP_ID=1000
|
||||
|
||||
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
|
||||
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
|
||||
|
||||
FROM ${BUILDER_IMAGE} AS builder
|
||||
|
||||
# install build dependencies
|
||||
RUN apt-get update -y && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install -y build-essential git && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# prepare build dir
|
||||
WORKDIR /app
|
||||
|
||||
# install hex + rebar
|
||||
RUN mix local.hex --force && \
|
||||
mix local.rebar --force
|
||||
|
||||
# set build ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
||||
# install mix dependencies
|
||||
COPY mix.exs mix.lock ./
|
||||
RUN mix deps.get --only $MIX_ENV
|
||||
RUN mkdir config
|
||||
|
||||
# copy compile-time config files before we compile dependencies
|
||||
# to ensure any relevant config change will trigger the dependencies
|
||||
# to be re-compiled.
|
||||
COPY config/config.exs config/${MIX_ENV}.exs config/
|
||||
RUN mix deps.compile
|
||||
|
||||
COPY priv priv
|
||||
|
||||
COPY lib lib
|
||||
|
||||
COPY assets assets
|
||||
|
||||
# compile assets
|
||||
RUN mix assets.deploy
|
||||
|
||||
# Compile the release
|
||||
RUN mix compile
|
||||
|
||||
# Changes to config/runtime.exs don't require recompiling the code
|
||||
COPY config/runtime.exs config/
|
||||
|
||||
COPY rel rel
|
||||
RUN mix release
|
||||
|
||||
# start a new build stage so that the final image will only contain
|
||||
# the compiled release and other runtime necessities
|
||||
FROM ${RUNNER_IMAGE}
|
||||
|
||||
# Install security updates and required packages
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update -y && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libstdc++6 \
|
||||
openssl \
|
||||
libncurses5 \
|
||||
locales \
|
||||
ca-certificates \
|
||||
tini && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
# Create a non-root user and group with specific ID
|
||||
groupadd -g 1000 aprs && \
|
||||
useradd -r -g aprs -u 1000 -s /bin/false -M aprs && \
|
||||
# Remove setuid and setgid permissions
|
||||
find / -perm /6000 -type f -exec chmod a-s {} \; || true && \
|
||||
# Secure system configurations
|
||||
chmod 0600 /etc/login.defs && \
|
||||
chmod 0600 /etc/passwd && \
|
||||
chmod 0600 /etc/group && \
|
||||
# Create and secure app directory
|
||||
mkdir -p /app && \
|
||||
chown aprs:aprs /app && \
|
||||
chmod 0750 /app
|
||||
|
||||
# Set the locale
|
||||
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||
|
||||
ENV LANG en_US.UTF-8
|
||||
ENV LANGUAGE en_US:en
|
||||
ENV LC_ALL en_US.UTF-8
|
||||
|
||||
WORKDIR "/app"
|
||||
|
||||
# Set security-related environment variables
|
||||
ENV MIX_ENV="prod" \
|
||||
LANG=en_US.UTF-8 \
|
||||
LANGUAGE=en_US:en \
|
||||
LC_ALL=en_US.UTF-8 \
|
||||
# Disable history files
|
||||
HISTFILE=/dev/null \
|
||||
# Prevent writing .erlang.cookie file
|
||||
HOME=/dev/null \
|
||||
# Add security headers
|
||||
SECURITY_HEADERS="true" \
|
||||
# Disable debug info in production
|
||||
ERL_AFLAGS="+S 1:1 +A 1 +K true -kernel shell_history enabled -kernel shell_history_file_bytes 0" \
|
||||
PHX_SERVER=true
|
||||
|
||||
# Only copy the final release from the build stage
|
||||
COPY --from=builder --chown=1000:1000 /app/_build/${MIX_ENV}/rel/aprs ./
|
||||
|
||||
USER 1000
|
||||
|
||||
# Ensure the server binary is executable
|
||||
RUN chmod +x /app/bin/server
|
||||
|
||||
# If using an environment that doesn't automatically reap zombie processes, it is
|
||||
# advised to add an init process such as tini via `apt-get install`
|
||||
# above and adding an entrypoint. See https://github.com/krallin/tini for details
|
||||
# ENTRYPOINT ["/tini", "--"]
|
||||
|
||||
# Add specific capabilities needed by the app
|
||||
CMD /app/bin/server
|
||||
|
||||
# Add security-related metadata
|
||||
LABEL org.opencontainers.image.vendor="APRS.me" \
|
||||
org.opencontainers.image.title="APRS.me Server" \
|
||||
org.opencontainers.image.description="APRS.me server with security hardening" \
|
||||
org.opencontainers.image.version="${MIX_ENV}" \
|
||||
org.opencontainers.image.created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
|
||||
security.root-user="false" \
|
||||
security.non-root-user="app" \
|
||||
security.privileged="false"
|
||||
|
||||
# Container configuration is handled by Dokku
|
||||
EXPOSE $PORT
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
ARG ELIXIR_VERSION=1.18.4
|
||||
ARG OTP_VERSION=27.2.4
|
||||
ARG DEBIAN_VERSION=bullseye-20250520-slim
|
||||
|
||||
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
|
||||
# Use distroless base image - this includes glibc and essential libraries
|
||||
ARG RUNNER_IMAGE="gcr.io/distroless/cc-debian11:nonroot"
|
||||
|
||||
FROM ${BUILDER_IMAGE} AS builder
|
||||
|
||||
# install build dependencies
|
||||
RUN apt-get update -y && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install -y build-essential git && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# prepare build dir
|
||||
WORKDIR /app
|
||||
|
||||
# install hex + rebar
|
||||
RUN mix local.hex --force && \
|
||||
mix local.rebar --force
|
||||
|
||||
# set build ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
||||
# install mix dependencies
|
||||
COPY mix.exs mix.lock ./
|
||||
RUN mix deps.get --only $MIX_ENV
|
||||
RUN mkdir config
|
||||
|
||||
# copy compile-time config files before we compile dependencies
|
||||
COPY config/config.exs config/${MIX_ENV}.exs config/
|
||||
RUN mix deps.compile
|
||||
|
||||
COPY priv priv
|
||||
COPY lib lib
|
||||
COPY assets assets
|
||||
|
||||
# compile assets
|
||||
RUN mix assets.deploy
|
||||
|
||||
# Compile the release
|
||||
RUN mix compile
|
||||
|
||||
# Changes to config/runtime.exs don't require recompiling the code
|
||||
COPY config/runtime.exs config/
|
||||
|
||||
COPY rel rel
|
||||
RUN mix release
|
||||
|
||||
# Final distroless stage
|
||||
FROM ${RUNNER_IMAGE}
|
||||
|
||||
# Set environment variables for production
|
||||
ENV MIX_ENV="prod" \
|
||||
LANG=C.UTF-8 \
|
||||
# Disable history files for security
|
||||
HISTFILE=/dev/null \
|
||||
# Set HOME to /tmp (writable in distroless)
|
||||
HOME=/tmp \
|
||||
# Configure Erlang VM
|
||||
ERL_AFLAGS="+S 1:1 +A 1 +K true" \
|
||||
PHX_SERVER=true
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the Elixir release from builder stage
|
||||
# nonroot user in distroless has UID 65532
|
||||
COPY --from=builder --chown=65532:65532 /app/_build/prod/rel/aprs ./
|
||||
|
||||
# Add security metadata
|
||||
LABEL org.opencontainers.image.vendor="APRS.me" \
|
||||
org.opencontainers.image.title="APRS.me Server (Distroless)" \
|
||||
org.opencontainers.image.description="APRS.me server with distroless base" \
|
||||
security.distroless="true" \
|
||||
security.nonroot="true"
|
||||
|
||||
# Run the server
|
||||
ENTRYPOINT ["/app/bin/server"]
|
||||
EXPOSE $PORT
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
ARG ELIXIR_VERSION=1.18.4
|
||||
ARG OTP_VERSION=27.2.4
|
||||
ARG DEBIAN_VERSION=bullseye-20250520-slim
|
||||
|
||||
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
|
||||
# Use static distroless image with only statically linked binaries
|
||||
ARG RUNNER_IMAGE="gcr.io/distroless/static-debian11:nonroot"
|
||||
|
||||
FROM ${BUILDER_IMAGE} AS builder
|
||||
|
||||
# install build dependencies
|
||||
RUN apt-get update -y && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install -y build-essential git && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# prepare build dir
|
||||
WORKDIR /app
|
||||
|
||||
# install hex + rebar
|
||||
RUN mix local.hex --force && \
|
||||
mix local.rebar --force
|
||||
|
||||
# set build ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
||||
# install mix dependencies
|
||||
COPY mix.exs mix.lock ./
|
||||
RUN mix deps.get --only $MIX_ENV
|
||||
RUN mkdir config
|
||||
|
||||
# copy compile-time config files before we compile dependencies
|
||||
COPY config/config.exs config/${MIX_ENV}.exs config/
|
||||
RUN mix deps.compile
|
||||
|
||||
COPY priv priv
|
||||
COPY lib lib
|
||||
COPY assets assets
|
||||
|
||||
# compile assets
|
||||
RUN mix assets.deploy
|
||||
|
||||
# Compile the release
|
||||
RUN mix compile
|
||||
|
||||
# Changes to config/runtime.exs don't require recompiling the code
|
||||
COPY config/runtime.exs config/
|
||||
|
||||
COPY rel rel
|
||||
RUN mix release
|
||||
|
||||
# Create intermediate stage to prepare statically linked binaries
|
||||
FROM debian:bullseye-slim AS static-builder
|
||||
|
||||
# Install tools to create static binaries
|
||||
RUN apt-get update -y && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
musl-tools \
|
||||
musl-dev \
|
||||
upx-ucl \
|
||||
file \
|
||||
&& apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy the release from builder
|
||||
COPY --from=builder /app/_build/prod/rel/aprs /app
|
||||
|
||||
# Create a wrapper script that can be statically linked
|
||||
RUN cat > /wrapper.c << 'EOF'
|
||||
#include <unistd.h>
|
||||
#include <sys/wait.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
char *args[] = {"/app/bin/server", "start", NULL};
|
||||
execv("/app/bin/server", args);
|
||||
perror("execv failed");
|
||||
return 1;
|
||||
}
|
||||
EOF
|
||||
|
||||
# This approach won't work well with Erlang/Elixir as they require dynamic linking
|
||||
# Let's use a different approach with a minimal runtime environment
|
||||
|
||||
# Runtime preparation stage
|
||||
FROM debian:bullseye-slim AS runtime-prep
|
||||
|
||||
# Install only the absolute minimum runtime dependencies
|
||||
RUN apt-get update -y && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libc6 \
|
||||
libssl1.1 \
|
||||
libcrypto++6 \
|
||||
libncurses5 \
|
||||
libtinfo5 \
|
||||
ca-certificates \
|
||||
&& apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy necessary libraries to a clean location
|
||||
RUN mkdir -p /runtime/lib/x86_64-linux-gnu /runtime/usr/lib/x86_64-linux-gnu /runtime/etc/ssl
|
||||
|
||||
# Copy essential libraries
|
||||
RUN cp -L /lib/x86_64-linux-gnu/libc.so.6 /runtime/lib/x86_64-linux-gnu/ && \
|
||||
cp -L /lib/x86_64-linux-gnu/libdl.so.2 /runtime/lib/x86_64-linux-gnu/ && \
|
||||
cp -L /lib/x86_64-linux-gnu/libpthread.so.0 /runtime/lib/x86_64-linux-gnu/ && \
|
||||
cp -L /lib/x86_64-linux-gnu/libm.so.6 /runtime/lib/x86_64-linux-gnu/ && \
|
||||
cp -L /lib/x86_64-linux-gnu/librt.so.1 /runtime/lib/x86_64-linux-gnu/ && \
|
||||
cp -L /lib/x86_64-linux-gnu/libtinfo.so.5 /runtime/lib/x86_64-linux-gnu/ && \
|
||||
cp -L /usr/lib/x86_64-linux-gnu/libssl.so.1.1 /runtime/usr/lib/x86_64-linux-gnu/ && \
|
||||
cp -L /usr/lib/x86_64-linux-gnu/libcrypto.so.1.1 /runtime/usr/lib/x86_64-linux-gnu/ && \
|
||||
cp -L /usr/lib/x86_64-linux-gnu/libncurses.so.5 /runtime/usr/lib/x86_64-linux-gnu/ && \
|
||||
cp -L /lib64/ld-linux-x86-64.so.2 /runtime/lib64/ || mkdir -p /runtime/lib64 && cp -L /lib64/ld-linux-x86-64.so.2 /runtime/lib64/
|
||||
|
||||
# Copy CA certificates
|
||||
RUN cp -r /etc/ssl/certs /runtime/etc/ssl/
|
||||
|
||||
# Final static distroless stage
|
||||
FROM ${RUNNER_IMAGE}
|
||||
|
||||
# Copy minimal runtime environment
|
||||
COPY --from=runtime-prep /runtime /
|
||||
|
||||
# Set environment variables
|
||||
ENV MIX_ENV="prod" \
|
||||
LANG=C.UTF-8 \
|
||||
PATH="/app/bin:$PATH" \
|
||||
HISTFILE=/dev/null \
|
||||
HOME=/tmp \
|
||||
ERL_AFLAGS="+S 1:1 +A 1 +K true" \
|
||||
PHX_SERVER=true
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the Elixir release
|
||||
COPY --from=builder --chown=65532:65532 /app/_build/prod/rel/aprs ./
|
||||
|
||||
# Security labels
|
||||
LABEL org.opencontainers.image.vendor="APRS.me" \
|
||||
org.opencontainers.image.title="APRS.me Server (Static Distroless)" \
|
||||
org.opencontainers.image.description="APRS.me server with static distroless base - maximum security" \
|
||||
security.distroless="true" \
|
||||
security.static="true" \
|
||||
security.nonroot="true" \
|
||||
security.no-shell="true"
|
||||
|
||||
# Note: Static distroless is very restrictive and may not work with all Erlang features
|
||||
# Consider using cc-debian11:nonroot for better compatibility
|
||||
|
||||
ENTRYPOINT ["/app/bin/server"]
|
||||
EXPOSE $PORT
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
ARG ELIXIR_VERSION=1.18.4
|
||||
ARG OTP_VERSION=27.2.4
|
||||
ARG DEBIAN_VERSION=bullseye-20250520-slim
|
||||
|
||||
# Set default non-root user and group IDs
|
||||
ARG USER_ID=1000
|
||||
ARG GROUP_ID=1000
|
||||
|
||||
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
|
||||
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
|
||||
|
||||
FROM ${BUILDER_IMAGE} AS builder
|
||||
|
||||
# install build dependencies
|
||||
RUN apt-get update -y && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install -y build-essential git && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# prepare build dir
|
||||
WORKDIR /app
|
||||
|
||||
# install hex + rebar
|
||||
RUN mix local.hex --force && \
|
||||
mix local.rebar --force
|
||||
|
||||
# set build ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
||||
# install mix dependencies
|
||||
COPY mix.exs mix.lock ./
|
||||
RUN mix deps.get --only $MIX_ENV
|
||||
RUN mkdir config
|
||||
|
||||
# copy compile-time config files before we compile dependencies
|
||||
# to ensure any relevant config change will trigger the dependencies
|
||||
# to be re-compiled.
|
||||
COPY config/config.exs config/${MIX_ENV}.exs config/
|
||||
RUN mix deps.compile
|
||||
|
||||
COPY priv priv
|
||||
|
||||
COPY lib lib
|
||||
|
||||
COPY assets assets
|
||||
|
||||
# compile assets
|
||||
RUN mix assets.deploy
|
||||
|
||||
# Compile the release
|
||||
RUN mix compile
|
||||
|
||||
# Changes to config/runtime.exs don't require recompiling the code
|
||||
COPY config/runtime.exs config/
|
||||
|
||||
COPY rel rel
|
||||
RUN mix release
|
||||
|
||||
# start a new build stage so that the final image will only contain
|
||||
# the compiled release and other runtime necessities
|
||||
FROM ${RUNNER_IMAGE}
|
||||
|
||||
# Install security updates and required packages
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update -y && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libstdc++6 \
|
||||
openssl \
|
||||
libncurses5 \
|
||||
locales \
|
||||
ca-certificates \
|
||||
tini && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
# Create a non-root user and group with specific ID
|
||||
groupadd -g 1000 aprs && \
|
||||
useradd -r -g aprs -u 1000 -s /bin/false -M aprs && \
|
||||
# Remove setuid and setgid permissions
|
||||
find / -perm /6000 -type f -exec chmod a-s {} \; || true && \
|
||||
# Secure system configurations
|
||||
chmod 0600 /etc/login.defs && \
|
||||
chmod 0600 /etc/passwd && \
|
||||
chmod 0600 /etc/group && \
|
||||
# Create and secure app directory
|
||||
mkdir -p /app && \
|
||||
chown aprs:aprs /app && \
|
||||
chmod 0750 /app
|
||||
|
||||
# Set the locale
|
||||
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||
|
||||
ENV LANG en_US.UTF-8
|
||||
ENV LANGUAGE en_US:en
|
||||
ENV LC_ALL en_US.UTF-8
|
||||
|
||||
WORKDIR "/app"
|
||||
|
||||
# Set security-related environment variables
|
||||
ENV MIX_ENV="prod" \
|
||||
LANG=en_US.UTF-8 \
|
||||
LANGUAGE=en_US:en \
|
||||
LC_ALL=en_US.UTF-8 \
|
||||
# Disable history files
|
||||
HISTFILE=/dev/null \
|
||||
# Prevent writing .erlang.cookie file
|
||||
HOME=/dev/null \
|
||||
# Add security headers
|
||||
SECURITY_HEADERS="true" \
|
||||
# Disable debug info in production
|
||||
ERL_AFLAGS="+S 1:1 +A 1 +K true -kernel shell_history enabled -kernel shell_history_file_bytes 0" \
|
||||
PHX_SERVER=true
|
||||
|
||||
# Only copy the final release from the build stage
|
||||
COPY --from=builder --chown=1000:1000 /app/_build/${MIX_ENV}/rel/aprs ./
|
||||
|
||||
USER 1000
|
||||
|
||||
# Ensure the server binary is executable
|
||||
RUN chmod +x /app/bin/server
|
||||
|
||||
# If using an environment that doesn't automatically reap zombie processes, it is
|
||||
# advised to add an init process such as tini via `apt-get install`
|
||||
# above and adding an entrypoint. See https://github.com/krallin/tini for details
|
||||
# ENTRYPOINT ["/tini", "--"]
|
||||
|
||||
# Add specific capabilities needed by the app
|
||||
CMD /app/bin/server
|
||||
|
||||
# Add security-related metadata
|
||||
LABEL org.opencontainers.image.vendor="APRS.me" \
|
||||
org.opencontainers.image.title="APRS.me Server" \
|
||||
org.opencontainers.image.description="APRS.me server with security hardening" \
|
||||
org.opencontainers.image.version="${MIX_ENV}" \
|
||||
org.opencontainers.image.created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
|
||||
security.root-user="false" \
|
||||
security.non-root-user="app" \
|
||||
security.privileged="false"
|
||||
|
||||
# Container configuration is handled by Dokku
|
||||
EXPOSE $PORT
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
# System Dependency Updates for APRS.me Docker Image
|
||||
|
||||
This document outlines strategies for keeping system dependencies updated in the APRS.me Docker image without modifying the existing Dockerfile, which could potentially break the build process.
|
||||
|
||||
## Current Challenges
|
||||
|
||||
The Dockerfile builds successfully but maintaining up-to-date system dependencies is important for security. Standard approaches like adding `unattended-upgrades` or using `--security` flags with `apt-get` have proven problematic in our build environment.
|
||||
|
||||
## Recommended Approaches
|
||||
|
||||
### 1. Regular Rebuilds
|
||||
|
||||
The most reliable way to keep system dependencies updated is through regular rebuilds:
|
||||
|
||||
```bash
|
||||
# Rebuild the Docker image with latest base image and dependencies
|
||||
docker build --no-cache --pull -t aprs:latest .
|
||||
```
|
||||
|
||||
Key flags:
|
||||
- `--no-cache`: Forces all layers to be rebuilt, including running `apt-get update` and `apt-get upgrade`
|
||||
- `--pull`: Ensures the latest version of the base image is used
|
||||
|
||||
### 2. Base Image Updates
|
||||
|
||||
Periodically update the base image version in the Dockerfile:
|
||||
|
||||
```diff
|
||||
- ARG DEBIAN_VERSION=bullseye-20250520-slim
|
||||
+ ARG DEBIAN_VERSION=bullseye-20250615-slim
|
||||
```
|
||||
|
||||
The Debian team regularly releases updated images with security patches.
|
||||
|
||||
### 3. CI/CD Integration
|
||||
|
||||
Automate the update process:
|
||||
|
||||
1. Set up a weekly GitHub Actions workflow to:
|
||||
- Build the image with `--no-cache --pull`
|
||||
- Run security scans with Trivy
|
||||
- Create a PR if updates are needed
|
||||
|
||||
2. Include base image version checks:
|
||||
```yaml
|
||||
- name: Check for newer base image
|
||||
run: |
|
||||
# Logic to check for newer base image versions
|
||||
# Create PR if newer version available
|
||||
```
|
||||
|
||||
### 4. Security Scanning
|
||||
|
||||
Regularly scan for vulnerabilities:
|
||||
|
||||
```bash
|
||||
# Install Trivy
|
||||
brew install aquasecurity/trivy/trivy # macOS
|
||||
# or appropriate command for your OS
|
||||
|
||||
# Scan the image
|
||||
trivy image aprs:latest
|
||||
```
|
||||
|
||||
### 5. Manual Update Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# update-deps.sh
|
||||
|
||||
# Pull latest base image
|
||||
docker pull debian:$(grep 'DEBIAN_VERSION=' Dockerfile | cut -d'=' -f2 | tr -d '"')
|
||||
|
||||
# Rebuild with latest dependencies
|
||||
docker build --no-cache --pull -t aprs:latest .
|
||||
|
||||
# Scan for vulnerabilities
|
||||
if command -v trivy &> /dev/null; then
|
||||
trivy image aprs:latest
|
||||
fi
|
||||
|
||||
echo "Image rebuilt with latest dependencies"
|
||||
```
|
||||
|
||||
## Deployment Strategy
|
||||
|
||||
1. Rebuild images at least weekly
|
||||
2. Deploy updated images after testing
|
||||
3. Monitor security advisories for critical updates
|
||||
4. Perform out-of-band updates for critical CVEs
|
||||
|
||||
## Monitoring
|
||||
|
||||
1. Set up alerts for high/critical vulnerabilities
|
||||
2. Subscribe to security mailing lists for Debian and key packages
|
||||
3. Use image scanning in your container registry
|
||||
|
||||
By following these approaches, you can maintain up-to-date system dependencies without modifying the Dockerfile in ways that might break the build process.
|
||||
|
|
@ -19,7 +19,8 @@ main {
|
|||
}
|
||||
|
||||
#map {
|
||||
height: calc(100vh - 60px); /* Adjust based on header height */
|
||||
height: calc(100vh - 60px);
|
||||
/* Adjust based on header height */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +61,7 @@ body.home-page main {
|
|||
height: 100vh;
|
||||
}
|
||||
|
||||
body.home-page main > div {
|
||||
body.home-page main>div {
|
||||
max-width: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
|
@ -70,6 +71,7 @@ body.home-page main > div {
|
|||
background-color: rgba(16, 185, 129, 0.8);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.marker-cluster-small div {
|
||||
background-color: rgba(16, 185, 129, 0.9);
|
||||
box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.3);
|
||||
|
|
@ -79,6 +81,7 @@ body.home-page main > div {
|
|||
background-color: rgba(59, 130, 246, 0.8);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.marker-cluster-medium div {
|
||||
background-color: rgba(59, 130, 246, 0.9);
|
||||
box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.3);
|
||||
|
|
@ -88,6 +91,7 @@ body.home-page main > div {
|
|||
background-color: rgba(239, 68, 68, 0.8);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.marker-cluster-large div {
|
||||
background-color: rgba(239, 68, 68, 0.9);
|
||||
box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.3);
|
||||
|
|
@ -99,6 +103,7 @@ body.home-page main > div {
|
|||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.marker-cluster div {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
|
|
@ -116,6 +121,7 @@ body.home-page main > div {
|
|||
line-height: 30px;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.marker-cluster span {
|
||||
line-height: 30px;
|
||||
}
|
||||
|
|
@ -138,7 +144,27 @@ body.home-page main > div {
|
|||
.aprs-marker {
|
||||
transition: transform 0.2s ease-out;
|
||||
}
|
||||
|
||||
.aprs-marker:hover {
|
||||
transform: scale(1.2);
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
|
||||
/* High-DPI support for APRS symbols */
|
||||
@media (-webkit-min-device-pixel-ratio: 2),
|
||||
(min-resolution: 192dpi) {
|
||||
.aprs-marker div[style*="aprs-symbols-24-0.png"] {
|
||||
background-image: url('/aprs-symbols/aprs-symbols-24-0@2x.png') !important;
|
||||
background-size: 384px 144px !important;
|
||||
}
|
||||
|
||||
.aprs-marker div[style*="aprs-symbols-24-1.png"] {
|
||||
background-image: url('/aprs-symbols/aprs-symbols-24-1@2x.png') !important;
|
||||
background-size: 384px 144px !important;
|
||||
}
|
||||
|
||||
.aprs-marker div[style*="aprs-symbols-24-2.png"] {
|
||||
background-image: url('/aprs-symbols/aprs-symbols-24-2@2x.png') !important;
|
||||
background-size: 384px 144px !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -100,10 +100,21 @@ let MinimalAPRSMap = {
|
|||
|
||||
// Initialize basic map
|
||||
try {
|
||||
// Check if map is already initialized
|
||||
if (this.map) {
|
||||
console.warn("Map already exists, reinitializing...");
|
||||
this.map.remove();
|
||||
this.map = null;
|
||||
}
|
||||
|
||||
// Ensure element ID is unique and not already initialized
|
||||
if (this.el._leaflet_id) {
|
||||
console.warn("Map already initialized on this element");
|
||||
return;
|
||||
console.warn("Map element already has Leaflet ID, cleaning up...");
|
||||
// Remove any existing Leaflet instance
|
||||
if (L.DomUtil.get(this.el.id)) {
|
||||
L.DomUtil.remove(L.DomUtil.get(this.el.id));
|
||||
}
|
||||
delete this.el._leaflet_id;
|
||||
}
|
||||
|
||||
this.map = L.map(this.el, {
|
||||
|
|
@ -115,15 +126,6 @@ let MinimalAPRSMap = {
|
|||
console.error("Error initializing map:", error);
|
||||
this.errors.push("Map initialization failed: " + error.message);
|
||||
|
||||
// Check if it's a container already initialized error
|
||||
if (error.message && error.message.includes("already initialized")) {
|
||||
// Try to get the existing map instance
|
||||
if (this.el._leaflet_id && L.DomUtil.get(this.el.id)) {
|
||||
console.warn("Attempting to recover existing map instance");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.initializationAttempts < this.maxInitializationAttempts) {
|
||||
setTimeout(() => this.attemptInitialization(), 1000);
|
||||
return;
|
||||
|
|
@ -602,55 +604,75 @@ let MinimalAPRSMap = {
|
|||
},
|
||||
|
||||
createMarkerIcon(data) {
|
||||
// Get symbol information
|
||||
const symbolTableId = data.symbol_table_id || "/";
|
||||
const symbolCode = data.symbol_code || ">";
|
||||
|
||||
// Determine sprite file based on symbol table
|
||||
let spriteFile;
|
||||
switch (symbolTableId) {
|
||||
case "/":
|
||||
spriteFile = "/aprs-symbols/aprs-symbols-24-0.png";
|
||||
break;
|
||||
case "\\":
|
||||
spriteFile = "/aprs-symbols/aprs-symbols-24-1.png";
|
||||
break;
|
||||
default:
|
||||
spriteFile = "/aprs-symbols/aprs-symbols-24-0.png";
|
||||
}
|
||||
|
||||
// Get the correct sprite sheet based on symbol table
|
||||
// Use high-DPI versions (@2x) for better quality
|
||||
const spriteFile = symbolTableId === "/"
|
||||
? "/aprs-symbols/aprs-symbols-24-0@2x.png"
|
||||
: "/aprs-symbols/aprs-symbols-24-1@2x.png";
|
||||
|
||||
// Calculate sprite position
|
||||
// The sprite sheet is organized as a 16x8 grid (128 symbols total)
|
||||
// ASCII codes 32-127 map to positions 0-95
|
||||
const charCode = symbolCode.charCodeAt(0);
|
||||
|
||||
// Convert ASCII to sprite sheet position
|
||||
// The sprite sheet is organized in a specific way:
|
||||
// - First row (0): ASCII 32-47
|
||||
// - Second row (1): ASCII 48-63
|
||||
// - Third row (2): ASCII 64-79
|
||||
// - Fourth row (3): ASCII 80-95
|
||||
// - Fifth row (4): ASCII 96-111
|
||||
// - Sixth row (5): ASCII 112-127
|
||||
// - Rows 6-7: Reserved for future use
|
||||
const position = charCode - 32;
|
||||
const col = position % 16;
|
||||
const row = Math.floor(position / 16);
|
||||
const x = -col * 24;
|
||||
const y = -row * 24;
|
||||
const column = position % 16;
|
||||
|
||||
// Each symbol is 48x48 pixels in @2x version (24x24 * 2)
|
||||
const x = -column * 48;
|
||||
const y = -row * 48;
|
||||
|
||||
// Use different opacity for historical markers
|
||||
const opacity = data.historical ? 0.7 : 1.0;
|
||||
// Debug info
|
||||
console.log('Symbol debug:', {
|
||||
symbolTableId,
|
||||
symbolCode,
|
||||
charCode,
|
||||
position,
|
||||
row,
|
||||
column,
|
||||
x,
|
||||
y,
|
||||
spriteFile
|
||||
});
|
||||
|
||||
// Create icon element
|
||||
const icon = document.createElement('div');
|
||||
icon.style.width = '24px';
|
||||
icon.style.height = '24px';
|
||||
icon.style.backgroundImage = `url(${spriteFile})`;
|
||||
icon.style.backgroundPosition = `${x}px ${y}px`;
|
||||
icon.style.backgroundSize = '768px 384px'; // 16x8 grid of 48x48 symbols (@2x)
|
||||
icon.style.backgroundRepeat = 'no-repeat';
|
||||
icon.style.imageRendering = 'pixelated'; // Ensure crisp pixel rendering
|
||||
icon.style.opacity = data.historical ? '0.7' : '1.0';
|
||||
|
||||
return L.divIcon({
|
||||
html: `<div style="
|
||||
background-image: url('${spriteFile}');
|
||||
background-position: ${x}px ${y}px;
|
||||
background-repeat: no-repeat;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
opacity: ${opacity};
|
||||
background-size: auto;
|
||||
"></div>`,
|
||||
className: data.historical ? "aprs-marker historical-marker" : "aprs-marker",
|
||||
html: icon,
|
||||
className: 'aprs-symbol',
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
popupAnchor: [0, -12],
|
||||
iconAnchor: [12, 12]
|
||||
});
|
||||
},
|
||||
|
||||
buildPopupContent(data) {
|
||||
const callsign = data.callsign || data.id || "Unknown";
|
||||
const comment = data.comment || "";
|
||||
const symbolDesc = data.symbol_description || "Unknown symbol";
|
||||
const symbolTableId = data.symbol_table_id || "/";
|
||||
const symbolCode = data.symbol_code || ">";
|
||||
const symbolDesc = data.symbol_description || `Symbol: ${symbolTableId}${symbolCode}`;
|
||||
|
||||
let content = `<div class="aprs-popup">
|
||||
<div class="aprs-callsign"><strong><a href="/${callsign}">${callsign}</a></strong></div>
|
||||
|
|
@ -666,7 +688,7 @@ let MinimalAPRSMap = {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
content += `</div>`;
|
||||
content += "</div>";
|
||||
return content;
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -51,10 +51,18 @@ defmodule Aprs.DbTest do
|
|||
{:ok, stored_packet} ->
|
||||
{:ok, stored_packet}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:error, :storage_exception} ->
|
||||
IO.puts("Failed to store packet! (storage exception)")
|
||||
{:error, :storage_exception}
|
||||
|
||||
{:error, :validation_error} ->
|
||||
IO.puts("Failed to store packet! (validation error)")
|
||||
{:error, :validation_error}
|
||||
|
||||
{:error, other_error} ->
|
||||
IO.puts("Failed to store packet!")
|
||||
IO.puts("Errors: #{inspect(changeset.errors, pretty: true)}")
|
||||
{:error, changeset}
|
||||
IO.puts("Error: #{inspect(other_error)}")
|
||||
{:error, other_error}
|
||||
end
|
||||
|
||||
error ->
|
||||
|
|
@ -123,7 +131,11 @@ defmodule Aprs.DbTest do
|
|||
where: p.has_position == true,
|
||||
order_by: [desc: p.received_at],
|
||||
limit: ^limit,
|
||||
select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)}
|
||||
select: %{
|
||||
p
|
||||
| lat: fragment("ST_Y(?)", p.location),
|
||||
lon: fragment("ST_X(?)", p.location)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -300,14 +300,6 @@ defmodule Aprs.Is do
|
|||
# Normalize data_type to string if it's an atom
|
||||
attrs = normalize_data_type(attrs)
|
||||
|
||||
# Ensure SSID is never nil
|
||||
attrs =
|
||||
if Map.has_key?(attrs, :ssid) and is_nil(attrs.ssid) do
|
||||
Map.put(attrs, :ssid, "0")
|
||||
else
|
||||
attrs
|
||||
end
|
||||
|
||||
# Store in database through the Packets context
|
||||
case Aprs.Packets.store_packet(attrs) do
|
||||
{:ok, _packet} ->
|
||||
|
|
@ -317,15 +309,11 @@ defmodule Aprs.Is do
|
|||
{:error, :storage_exception} ->
|
||||
Logger.error("Storage exception while storing packet from #{inspect(parsed_message.sender)}")
|
||||
|
||||
# Log the problematic attributes for debugging
|
||||
Logger.debug("Packet attributes that failed: #{inspect(attrs)}")
|
||||
|
||||
{:error, changeset} when is_map(changeset) and is_map_key(changeset, :errors) ->
|
||||
Logger.error(
|
||||
"Failed to store packet from #{inspect(parsed_message.sender)}: #{inspect(changeset.errors)}"
|
||||
)
|
||||
{:error, :validation_error} ->
|
||||
Logger.error("Validation error while storing packet from #{inspect(parsed_message.sender)}")
|
||||
|
||||
# Log the problematic attributes for debugging
|
||||
Logger.debug("Packet attributes that failed: #{inspect(attrs)}")
|
||||
|
||||
{:error, other_error} ->
|
||||
|
|
@ -333,7 +321,6 @@ defmodule Aprs.Is do
|
|||
"Unknown error storing packet from #{inspect(parsed_message.sender)}: #{inspect(other_error)}"
|
||||
)
|
||||
|
||||
# Log the problematic attributes for debugging
|
||||
Logger.debug("Packet attributes that failed: #{inspect(attrs)}")
|
||||
end
|
||||
else
|
||||
|
|
@ -342,6 +329,7 @@ defmodule Aprs.Is do
|
|||
rescue
|
||||
error ->
|
||||
Logger.error("Exception while storing packet from #{inspect(parsed_message.sender)}: #{inspect(error)}")
|
||||
|
||||
Logger.debug("Raw message: #{inspect(message)}")
|
||||
Logger.debug("Parsed message: #{inspect(parsed_message)}")
|
||||
end
|
||||
|
|
@ -363,11 +351,18 @@ defmodule Aprs.Is do
|
|||
{:error, :invalid_packet} ->
|
||||
Logger.debug("PARSE ERROR: invalid packet")
|
||||
|
||||
Aprs.Packets.store_bad_packet(message, %{
|
||||
message: "Invalid packet format",
|
||||
type: "ParseError"
|
||||
})
|
||||
|
||||
{:error, error} ->
|
||||
Logger.debug("PARSE ERROR: " <> error)
|
||||
Aprs.Packets.store_bad_packet(message, %{message: error, type: "ParseError"})
|
||||
|
||||
x ->
|
||||
Logger.debug("PARSE ERROR: " <> x)
|
||||
Aprs.Packets.store_bad_packet(message, %{message: inspect(x), type: "ParseError"})
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -381,7 +376,8 @@ defmodule Aprs.Is do
|
|||
Logger.debug("Packet has nil data_extended: #{inspect(packet.sender)}")
|
||||
false
|
||||
|
||||
%{data_extended: %{latitude: lat, longitude: lon}} when not is_nil(lat) and not is_nil(lon) ->
|
||||
%{data_extended: %{latitude: lat, longitude: lon}}
|
||||
when not is_nil(lat) and not is_nil(lon) ->
|
||||
# Check if coordinates are valid numbers
|
||||
valid = are_valid_coords?(lat, lon)
|
||||
|
||||
|
|
@ -409,7 +405,6 @@ defmodule Aprs.Is do
|
|||
false
|
||||
end
|
||||
|
||||
Logger.debug("Position data check for #{inspect(packet.sender)}: #{result}")
|
||||
result
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -239,27 +239,45 @@ defmodule Aprs.Packet do
|
|||
defp extract_from_map(data_extended) do
|
||||
%{}
|
||||
|> maybe_put(:symbol_code, data_extended[:symbol_code] || data_extended["symbol_code"])
|
||||
|> maybe_put(:symbol_table_id, data_extended[:symbol_table_id] || data_extended["symbol_table_id"])
|
||||
|> maybe_put(
|
||||
:symbol_table_id,
|
||||
data_extended[:symbol_table_id] || data_extended["symbol_table_id"]
|
||||
)
|
||||
|> maybe_put(:comment, data_extended[:comment] || data_extended["comment"])
|
||||
|> maybe_put(:timestamp, data_extended[:timestamp] || data_extended["timestamp"])
|
||||
|> maybe_put(:aprs_messaging, data_extended[:aprs_messaging?] || data_extended["aprs_messaging?"])
|
||||
|> maybe_put(
|
||||
:aprs_messaging,
|
||||
data_extended[:aprs_messaging?] || data_extended["aprs_messaging?"]
|
||||
)
|
||||
|> maybe_put(:temperature, data_extended[:temperature] || data_extended["temperature"])
|
||||
|> maybe_put(:humidity, data_extended[:humidity] || data_extended["humidity"])
|
||||
|> maybe_put(:wind_speed, data_extended[:wind_speed] || data_extended["wind_speed"])
|
||||
|> maybe_put(:wind_direction, data_extended[:wind_direction] || data_extended["wind_direction"])
|
||||
|> maybe_put(
|
||||
:wind_direction,
|
||||
data_extended[:wind_direction] || data_extended["wind_direction"]
|
||||
)
|
||||
|> maybe_put(:wind_gust, data_extended[:wind_gust] || data_extended["wind_gust"])
|
||||
|> maybe_put(:pressure, data_extended[:pressure] || data_extended["pressure"])
|
||||
|> maybe_put(:rain_1h, data_extended[:rain_1h] || data_extended["rain_1h"])
|
||||
|> maybe_put(:rain_24h, data_extended[:rain_24h] || data_extended["rain_24h"])
|
||||
|> maybe_put(:rain_since_midnight, data_extended[:rain_since_midnight] || data_extended["rain_since_midnight"])
|
||||
|> maybe_put(
|
||||
:rain_since_midnight,
|
||||
data_extended[:rain_since_midnight] || data_extended["rain_since_midnight"]
|
||||
)
|
||||
|> maybe_put(:manufacturer, data_extended[:manufacturer] || data_extended["manufacturer"])
|
||||
|> maybe_put(:equipment_type, data_extended[:equipment_type] || data_extended["equipment_type"])
|
||||
|> maybe_put(
|
||||
:equipment_type,
|
||||
data_extended[:equipment_type] || data_extended["equipment_type"]
|
||||
)
|
||||
|> maybe_put(:course, data_extended[:course] || data_extended["course"])
|
||||
|> maybe_put(:speed, data_extended[:speed] || data_extended["speed"])
|
||||
|> maybe_put(:altitude, data_extended[:altitude] || data_extended["altitude"])
|
||||
|> maybe_put(:addressee, data_extended[:addressee] || data_extended["addressee"])
|
||||
|> maybe_put(:message_text, data_extended[:message_text] || data_extended["message_text"])
|
||||
|> maybe_put(:message_number, data_extended[:message_number] || data_extended["message_number"])
|
||||
|> maybe_put(
|
||||
:message_number,
|
||||
data_extended[:message_number] || data_extended["message_number"]
|
||||
)
|
||||
|> extract_weather_data(data_extended)
|
||||
end
|
||||
|
||||
|
|
@ -270,10 +288,8 @@ defmodule Aprs.Packet do
|
|||
|> maybe_put(:manufacturer, mic_e.manufacturer)
|
||||
|> maybe_put(:course, mic_e.heading)
|
||||
|> maybe_put(:speed, mic_e.speed)
|
||||
# Default car symbol for MicE
|
||||
|> maybe_put(:symbol_code, ">")
|
||||
# Primary table
|
||||
|> maybe_put(:symbol_table_id, "/")
|
||||
|> maybe_put(:symbol_code, mic_e.symbol_code)
|
||||
|> maybe_put(:symbol_table_id, mic_e.symbol_table_id)
|
||||
end
|
||||
|
||||
# Extract data from converted MicE map (from struct_to_map conversion)
|
||||
|
|
@ -313,7 +329,10 @@ defmodule Aprs.Packet do
|
|||
# This is a simplified parser - a full implementation would handle
|
||||
# the complete APRS weather format specification
|
||||
attrs
|
||||
|> maybe_extract_weather_field(weather_string, ~r/(\d{3})\/(\d{3})/, [:wind_direction, :wind_speed])
|
||||
|> maybe_extract_weather_field(weather_string, ~r/(\d{3})\/(\d{3})/, [
|
||||
:wind_direction,
|
||||
:wind_speed
|
||||
])
|
||||
|> maybe_extract_weather_field(weather_string, ~r/t(\d{3})/, [:temperature])
|
||||
|> maybe_extract_weather_field(weather_string, ~r/h(\d{2})/, [:humidity])
|
||||
|> maybe_extract_weather_field(weather_string, ~r/b(\d{5})/, [:pressure])
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ defmodule Aprs.Packets do
|
|||
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias Aprs.BadPacket
|
||||
alias Aprs.EncodingUtils
|
||||
alias Aprs.Packet
|
||||
alias Aprs.Repo
|
||||
|
|
@ -63,27 +64,81 @@ defmodule Aprs.Packets do
|
|||
|> Map.put(:region, "#{Float.round(lat, 1)},#{Float.round(lon, 1)}")
|
||||
else
|
||||
Logger.debug("Invalid coordinates for packet from #{packet_attrs[:sender]}: lat=#{lat}, lon=#{lon}")
|
||||
|
||||
# Set region based on callsign if coordinates are invalid
|
||||
sender_region = if packet_attrs[:sender], do: String.slice(packet_attrs.sender || "", 0, 3), else: "unknown"
|
||||
sender_region =
|
||||
if packet_attrs[:sender],
|
||||
do: String.slice(packet_attrs.sender || "", 0, 3),
|
||||
else: "unknown"
|
||||
|
||||
Map.put(packet_attrs, :region, "call:#{sender_region}")
|
||||
end
|
||||
else
|
||||
# Set region based on callsign if no position
|
||||
sender_region = if packet_attrs[:sender], do: String.slice(packet_attrs.sender || "", 0, 3), else: "unknown"
|
||||
sender_region =
|
||||
if packet_attrs[:sender],
|
||||
do: String.slice(packet_attrs.sender || "", 0, 3),
|
||||
else: "unknown"
|
||||
|
||||
Map.put(packet_attrs, :region, "call:#{sender_region}")
|
||||
end
|
||||
|
||||
# Insert the packet
|
||||
%Packet{}
|
||||
|> Packet.changeset(packet_attrs)
|
||||
|> Repo.insert()
|
||||
case %Packet{}
|
||||
|> Packet.changeset(packet_attrs)
|
||||
|> Repo.insert() do
|
||||
{:ok, packet} ->
|
||||
{:ok, packet}
|
||||
|
||||
{:error, changeset} ->
|
||||
# Store validation errors as bad packets
|
||||
error_message =
|
||||
Enum.map_join(changeset.errors, ", ", fn {field, {msg, _}} -> "#{field}: #{msg}" end)
|
||||
|
||||
store_bad_packet(packet_data, %{message: error_message, type: "ValidationError"})
|
||||
{:error, :validation_error}
|
||||
end
|
||||
rescue
|
||||
error ->
|
||||
Logger.error("Exception in store_packet for #{inspect(packet_data[:sender])}: #{inspect(error)}")
|
||||
|
||||
store_bad_packet(packet_data, error)
|
||||
{:error, :storage_exception}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stores a bad packet in the database.
|
||||
|
||||
## Parameters
|
||||
* `packet_data` - The original packet data that failed to parse
|
||||
* `error` - The error that occurred during parsing
|
||||
"""
|
||||
def store_bad_packet(packet_data, error) do
|
||||
error_type =
|
||||
case error do
|
||||
%{type: type} -> type
|
||||
%{__struct__: struct} -> struct
|
||||
_ -> "UnknownError"
|
||||
end
|
||||
|
||||
error_message =
|
||||
case error do
|
||||
%{message: message} -> message
|
||||
%{__struct__: _} -> Exception.message(error)
|
||||
_ -> inspect(error)
|
||||
end
|
||||
|
||||
%BadPacket{}
|
||||
|> BadPacket.changeset(%{
|
||||
raw_packet: inspect(packet_data),
|
||||
error_message: error_message,
|
||||
error_type: error_type,
|
||||
attempted_at: DateTime.utc_now()
|
||||
})
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
# Extracts position data from packet, checking various possible locations
|
||||
defp extract_position(packet_data) do
|
||||
cond do
|
||||
|
|
@ -97,7 +152,8 @@ defmodule Aprs.Packets do
|
|||
|
||||
cond do
|
||||
# Standard position format
|
||||
is_map(data_extended) and not is_nil(data_extended[:latitude]) and not is_nil(data_extended[:longitude]) ->
|
||||
is_map(data_extended) and not is_nil(data_extended[:latitude]) and
|
||||
not is_nil(data_extended[:longitude]) ->
|
||||
{to_float(data_extended[:latitude]), to_float(data_extended[:longitude])}
|
||||
|
||||
# MicE packet format with components
|
||||
|
|
@ -184,7 +240,9 @@ defmodule Aprs.Packets do
|
|||
defp filter_by_time(query, %{start_time: start_time, end_time: end_time}) do
|
||||
# Ensure we prioritize packets from the last hour
|
||||
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||
effective_start_time = if DateTime.before?(start_time, one_hour_ago), do: one_hour_ago, else: start_time
|
||||
|
||||
effective_start_time =
|
||||
if DateTime.before?(start_time, one_hour_ago), do: one_hour_ago, else: start_time
|
||||
|
||||
from p in query,
|
||||
where: p.received_at >= ^effective_start_time and p.received_at <= ^end_time
|
||||
|
|
|
|||
|
|
@ -452,8 +452,8 @@ defmodule AprsWeb.CoreComponents do
|
|||
|
||||
def table(assigns) do
|
||||
~H"""
|
||||
<div id={@id} class="overflow-y-auto px-4 sm:overflow-visible sm:px-0">
|
||||
<table class="mt-11 w-[40rem] sm:w-full">
|
||||
<div id={@id} class="overflow-y-auto px-0 sm:overflow-visible sm:px-0">
|
||||
<table class="w-[40rem] sm:w-full">
|
||||
<thead class="text-left text-[0.8125rem] leading-6 text-zinc-500">
|
||||
<tr>
|
||||
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal">{col[:label]}</th>
|
||||
|
|
@ -472,7 +472,7 @@ defmodule AprsWeb.CoreComponents do
|
|||
class={["p-0", @row_click && "hover:cursor-pointer"]}
|
||||
>
|
||||
<div :if={i == 0}>
|
||||
<span class="absolute h-full w-4 top-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
|
||||
<span class="absolute h-full w-4 top-0 left-0 group-hover:bg-zinc-50 sm:rounded-l-xl" />
|
||||
<span class="absolute h-full w-4 top-0 -right-4 group-hover:bg-zinc-50 sm:rounded-r-xl" />
|
||||
</div>
|
||||
<div class="block py-4 pr-6">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<main class="px-4 py-20 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<main>
|
||||
<div>
|
||||
<.flash kind={:info} title="Success!" flash={@flash} />
|
||||
<.flash kind={:error} title="Error!" flash={@flash} />
|
||||
<.flash
|
||||
|
|
|
|||
|
|
@ -163,21 +163,189 @@ defmodule AprsWeb.Helpers.AprsSymbols do
|
|||
"""
|
||||
def symbol_description(symbol_table_id, symbol_code) do
|
||||
case {symbol_table_id, symbol_code} do
|
||||
# Primary Table (/)
|
||||
{"/", "!"} -> "Police/Sheriff"
|
||||
{"/", "\""} -> "Reserved"
|
||||
{"/", "#"} -> "Digipeater"
|
||||
{"/", "$"} -> "Phone"
|
||||
{"/", "%"} -> "DX Cluster"
|
||||
{"/", "&"} -> "HF Gateway"
|
||||
{"/", "'"} -> "Small Aircraft"
|
||||
{"/", "("} -> "Mobile Satellite Station"
|
||||
{"/", ")"} -> "Wheelchair"
|
||||
{"/", "*"} -> "Snowmobile"
|
||||
{"/", "+"} -> "Red Cross"
|
||||
{"/", ","} -> "Boy Scout"
|
||||
{"/", "-"} -> "House"
|
||||
{"/", "."} -> "X"
|
||||
{"/", "/"} -> "Position"
|
||||
{"/", "0"} -> "Circle"
|
||||
{"/", "1"} -> "Circle"
|
||||
{"/", "2"} -> "Circle"
|
||||
{"/", "3"} -> "Circle"
|
||||
{"/", "4"} -> "Circle"
|
||||
{"/", "5"} -> "Circle"
|
||||
{"/", "6"} -> "Circle"
|
||||
{"/", "7"} -> "Circle"
|
||||
{"/", "8"} -> "Circle"
|
||||
{"/", "9"} -> "Circle"
|
||||
{"/", ":"} -> "Fire Department"
|
||||
{"/", ";"} -> "Campground"
|
||||
{"/", "<"} -> "Motorcycle"
|
||||
{"/", "="} -> "Rail Engine"
|
||||
{"/", ">"} -> "Car"
|
||||
{"/", "k"} -> "Truck"
|
||||
{"/", "j"} -> "Jeep"
|
||||
{"/", "f"} -> "Fire truck"
|
||||
{"/", "?"} -> "File Server"
|
||||
{"/", "@"} -> "HC Future"
|
||||
{"/", "A"} -> "Aid Station"
|
||||
{"/", "B"} -> "BBS"
|
||||
{"/", "C"} -> "Canoe"
|
||||
{"/", "D"} -> "Reserved"
|
||||
{"/", "E"} -> "Eyeball"
|
||||
{"/", "F"} -> "Tractor"
|
||||
{"/", "G"} -> "Grid Square"
|
||||
{"/", "H"} -> "Hotel"
|
||||
{"/", "I"} -> "TCP/IP"
|
||||
{"/", "J"} -> "Phone"
|
||||
{"/", "K"} -> "School"
|
||||
{"/", "L"} -> "PC User"
|
||||
{"/", "M"} -> "MacAPRS"
|
||||
{"/", "N"} -> "NTS Station"
|
||||
{"/", "O"} -> "Balloon"
|
||||
{"/", "P"} -> "Police"
|
||||
{"/", "Q"} -> "TBD"
|
||||
{"/", "R"} -> "Recreational Vehicle"
|
||||
{"/", "S"} -> "Shuttle"
|
||||
{"/", "T"} -> "SSTV"
|
||||
{"/", "U"} -> "Bus"
|
||||
{"/", "V"} -> "ATV"
|
||||
{"/", "W"} -> "National Weather Service"
|
||||
{"/", "X"} -> "Helo"
|
||||
{"/", "Y"} -> "Yacht"
|
||||
{"/", "Z"} -> "WinAPRS"
|
||||
{"/", "["} -> "Jogger"
|
||||
{"/", "\\"} -> "Triangle"
|
||||
{"/", "]"} -> "PBBS"
|
||||
{"/", "^"} -> "Aircraft"
|
||||
{"/", "_"} -> "Weather Station"
|
||||
{"/", "`"} -> "Dish Antenna"
|
||||
{"/", "a"} -> "Ambulance"
|
||||
{"/", "b"} -> "Bike"
|
||||
{"/", "c"} -> "Incident Command Post"
|
||||
{"/", "d"} -> "Fire Depts"
|
||||
{"/", "e"} -> "Horse"
|
||||
{"/", "f"} -> "Fire Truck"
|
||||
{"/", "g"} -> "Glider"
|
||||
{"/", "^"} -> "Aircraft"
|
||||
{"/", "s"} -> "Ship"
|
||||
{"/", "Y"} -> "Yacht"
|
||||
{"/", "-"} -> "House"
|
||||
{"/", "h"} -> "Hospital"
|
||||
{"/", "i"} -> "IOTA"
|
||||
{"/", "j"} -> "Jeep"
|
||||
{"/", "k"} -> "Truck"
|
||||
{"/", "l"} -> "Laptop"
|
||||
{"/", "m"} -> "Mic-E"
|
||||
{"/", "n"} -> "Node"
|
||||
{"/", "o"} -> "EOC"
|
||||
{"/", "p"} -> "Dog"
|
||||
{"/", "q"} -> "Grid"
|
||||
{"/", "r"} -> "Repeater"
|
||||
{"/", "/"} -> "Position"
|
||||
{"/", "s"} -> "Ship"
|
||||
{"/", "t"} -> "Truck Stop"
|
||||
{"/", "u"} -> "Truck"
|
||||
{"/", "v"} -> "Van"
|
||||
{"/", "w"} -> "Water Station"
|
||||
{"/", "x"} -> "X-APRS"
|
||||
{"/", "y"} -> "Yagi"
|
||||
{"/", "z"} -> "Shelter"
|
||||
# Secondary Table (\)
|
||||
{"\\", "!"} -> "Emergency"
|
||||
{"\\", "\""} -> "Reserved"
|
||||
{"\\", "#"} -> "Digipeater"
|
||||
{"\\", "$"} -> "Bank"
|
||||
{"\\", "%"} -> "Reserved"
|
||||
{"\\", "&"} -> "Reserved"
|
||||
{"\\", "'"} -> "Reserved"
|
||||
{"\\", "("} -> "Reserved"
|
||||
{"\\", ")"} -> "Reserved"
|
||||
{"\\", "*"} -> "Reserved"
|
||||
{"\\", "+"} -> "Reserved"
|
||||
{"\\", ","} -> "Reserved"
|
||||
{"\\", "-"} -> "Reserved"
|
||||
{"\\", "."} -> "Reserved"
|
||||
{"\\", "/"} -> "Triangle"
|
||||
{"\\", "0"} -> "Reserved"
|
||||
{"\\", "1"} -> "Reserved"
|
||||
{"\\", "2"} -> "Reserved"
|
||||
{"\\", "3"} -> "Reserved"
|
||||
{"\\", "4"} -> "Reserved"
|
||||
{"\\", "5"} -> "Reserved"
|
||||
{"\\", "6"} -> "Reserved"
|
||||
{"\\", "7"} -> "Reserved"
|
||||
{"\\", "8"} -> "Reserved"
|
||||
{"\\", "9"} -> "Reserved"
|
||||
{"\\", ":"} -> "Reserved"
|
||||
{"\\", ";"} -> "Reserved"
|
||||
{"\\", "<"} -> "Reserved"
|
||||
{"\\", "="} -> "Reserved"
|
||||
{"\\", ">"} -> "Car (alternate)"
|
||||
{"\\", "?"} -> "Reserved"
|
||||
{"\\", "@"} -> "Reserved"
|
||||
{"\\", "A"} -> "Reserved"
|
||||
{"\\", "B"} -> "Reserved"
|
||||
{"\\", "C"} -> "Reserved"
|
||||
{"\\", "D"} -> "Reserved"
|
||||
{"\\", "E"} -> "Reserved"
|
||||
{"\\", "F"} -> "Reserved"
|
||||
{"\\", "G"} -> "Reserved"
|
||||
{"\\", "H"} -> "Reserved"
|
||||
{"\\", "I"} -> "Reserved"
|
||||
{"\\", "J"} -> "Reserved"
|
||||
{"\\", "K"} -> "Reserved"
|
||||
{"\\", "L"} -> "Reserved"
|
||||
{"\\", "M"} -> "Reserved"
|
||||
{"\\", "N"} -> "Reserved"
|
||||
{"\\", "O"} -> "Reserved"
|
||||
{"\\", "P"} -> "Reserved"
|
||||
{"\\", "Q"} -> "Reserved"
|
||||
{"\\", "R"} -> "Reserved"
|
||||
{"\\", "S"} -> "Reserved"
|
||||
{"\\", "T"} -> "Reserved"
|
||||
{"\\", "U"} -> "Reserved"
|
||||
{"\\", "V"} -> "Reserved"
|
||||
{"\\", "W"} -> "Reserved"
|
||||
{"\\", "X"} -> "Reserved"
|
||||
{"\\", "Y"} -> "Reserved"
|
||||
{"\\", "Z"} -> "Reserved"
|
||||
{"\\", "["} -> "Reserved"
|
||||
{"\\", "\\"} -> "Reserved"
|
||||
{"\\", "]"} -> "Reserved"
|
||||
{"\\", "^"} -> "Reserved"
|
||||
{"\\", "_"} -> "Reserved"
|
||||
{"\\", "`"} -> "Reserved"
|
||||
{"\\", "a"} -> "Reserved"
|
||||
{"\\", "b"} -> "Reserved"
|
||||
{"\\", "c"} -> "Reserved"
|
||||
{"\\", "d"} -> "Reserved"
|
||||
{"\\", "e"} -> "Reserved"
|
||||
{"\\", "f"} -> "Reserved"
|
||||
{"\\", "g"} -> "Reserved"
|
||||
{"\\", "h"} -> "Reserved"
|
||||
{"\\", "i"} -> "Reserved"
|
||||
{"\\", "j"} -> "Reserved"
|
||||
{"\\", "k"} -> "Reserved"
|
||||
{"\\", "l"} -> "Reserved"
|
||||
{"\\", "m"} -> "Reserved"
|
||||
{"\\", "n"} -> "Reserved"
|
||||
{"\\", "o"} -> "Reserved"
|
||||
{"\\", "p"} -> "Reserved"
|
||||
{"\\", "q"} -> "Reserved"
|
||||
{"\\", "r"} -> "Reserved"
|
||||
{"\\", "s"} -> "Reserved"
|
||||
{"\\", "t"} -> "Reserved"
|
||||
{"\\", "u"} -> "Reserved"
|
||||
{"\\", "v"} -> "Reserved"
|
||||
{"\\", "w"} -> "Reserved"
|
||||
{"\\", "x"} -> "Reserved"
|
||||
{"\\", "y"} -> "Reserved"
|
||||
{"\\", "z"} -> "Reserved"
|
||||
# Unknown symbol
|
||||
_ -> "Unknown symbol"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,94 +17,87 @@ defmodule AprsWeb.MapLive.Index do
|
|||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
# Calculate one hour ago for packet age filtering
|
||||
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||
|
||||
socket =
|
||||
assign(socket,
|
||||
packets: [],
|
||||
page_title: "APRS Map",
|
||||
# Track visible packets by callsign
|
||||
visible_packets: %{},
|
||||
# Default bounds for USA
|
||||
map_bounds: %{
|
||||
north: 49.0,
|
||||
south: 24.0,
|
||||
east: -66.0,
|
||||
west: -125.0
|
||||
},
|
||||
map_center: @default_center,
|
||||
map_zoom: @default_zoom,
|
||||
# Replay controls
|
||||
replay_active: false,
|
||||
replay_speed: @default_replay_speed,
|
||||
replay_paused: false,
|
||||
replay_packets: [],
|
||||
replay_index: 0,
|
||||
replay_timer_ref: nil,
|
||||
replay_start_time: nil,
|
||||
replay_end_time: nil,
|
||||
# Map of packet IDs to packet data for historical packets
|
||||
historical_packets: %{},
|
||||
# Timestamp for filtering out old packets
|
||||
packet_age_threshold: one_hour_ago,
|
||||
# Flag to indicate if map is ready for replay
|
||||
map_ready: false,
|
||||
# Flag to prevent multiple replay starts
|
||||
replay_started: false,
|
||||
# Pending geolocation to zoom to after map is ready
|
||||
pending_geolocation: nil,
|
||||
# Timer for debounced bounds updates
|
||||
bounds_update_timer: nil,
|
||||
# Pending bounds update data
|
||||
pending_bounds: nil
|
||||
)
|
||||
socket = assign_defaults(socket, one_hour_ago)
|
||||
|
||||
if connected?(socket) do
|
||||
Endpoint.subscribe("aprs_messages")
|
||||
|
||||
# Only do IP geolocation in non-test environments
|
||||
if Application.get_env(:aprs, :disable_aprs_connection, false) != true do
|
||||
IO.puts("Socket is connected, attempting to get IP location")
|
||||
# Get IP-based location on initial load
|
||||
IO.puts("Connect info: #{inspect(socket.private[:connect_info])}")
|
||||
IO.puts("Peer data: #{inspect(socket.private[:connect_info][:peer_data])}")
|
||||
|
||||
ip =
|
||||
case socket.private[:connect_info][:peer_data][:address] do
|
||||
{a, b, c, d} -> "#{a}.#{b}.#{c}.#{d}"
|
||||
{a, b, c, d, e, f, g, h} -> "#{a}:#{b}:#{c}:#{d}:#{e}:#{f}:#{g}:#{h}"
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
# Only attempt IP geolocation if not localhost
|
||||
if ip && !String.starts_with?(ip, "127.") && !String.starts_with?(ip, "::1") do
|
||||
IO.puts("Starting IP geolocation task for IP: #{ip}")
|
||||
# Start as a separate task and await the result
|
||||
Task.start(fn ->
|
||||
try do
|
||||
get_ip_location(ip)
|
||||
rescue
|
||||
error ->
|
||||
IO.puts("Error in IP geolocation task: #{inspect(error)}")
|
||||
IO.puts("Stacktrace: #{inspect(__STACKTRACE__)}")
|
||||
send(self(), {:ip_location, @default_center})
|
||||
end
|
||||
end)
|
||||
else
|
||||
IO.puts("No IP address found, skipping geolocation")
|
||||
end
|
||||
end
|
||||
|
||||
# Schedule regular cleanup of old packets from the map
|
||||
Process.send_after(self(), :cleanup_old_packets, 60_000)
|
||||
# Schedule initialization of replay after a short delay
|
||||
Process.send_after(self(), :initialize_replay, 2000)
|
||||
maybe_start_geolocation(socket)
|
||||
schedule_timers()
|
||||
end
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
defp assign_defaults(socket, one_hour_ago) do
|
||||
assign(socket,
|
||||
packets: [],
|
||||
page_title: "APRS Map",
|
||||
visible_packets: %{},
|
||||
map_bounds: %{
|
||||
north: 49.0,
|
||||
south: 24.0,
|
||||
east: -66.0,
|
||||
west: -125.0
|
||||
},
|
||||
map_center: @default_center,
|
||||
map_zoom: @default_zoom,
|
||||
replay_active: false,
|
||||
replay_speed: @default_replay_speed,
|
||||
replay_paused: false,
|
||||
replay_packets: [],
|
||||
replay_index: 0,
|
||||
replay_timer_ref: nil,
|
||||
replay_start_time: nil,
|
||||
replay_end_time: nil,
|
||||
historical_packets: %{},
|
||||
packet_age_threshold: one_hour_ago,
|
||||
map_ready: false,
|
||||
replay_started: false,
|
||||
pending_geolocation: nil,
|
||||
bounds_update_timer: nil,
|
||||
pending_bounds: nil
|
||||
)
|
||||
end
|
||||
|
||||
defp maybe_start_geolocation(socket) do
|
||||
if Application.get_env(:aprs, :disable_aprs_connection, false) != true do
|
||||
IO.puts("Socket is connected, attempting to get IP location")
|
||||
IO.puts("Connect info: #{inspect(socket.private[:connect_info])}")
|
||||
IO.puts("Peer data: #{inspect(socket.private[:connect_info][:peer_data])}")
|
||||
|
||||
ip =
|
||||
case socket.private[:connect_info][:peer_data][:address] do
|
||||
{a, b, c, d} -> "#{a}.#{b}.#{c}.#{d}"
|
||||
{a, b, c, d, e, f, g, h} -> "#{a}:#{b}:#{c}:#{d}:#{e}:#{f}:#{g}:#{h}"
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
if ip && !String.starts_with?(ip, "127.") && !String.starts_with?(ip, "::1") do
|
||||
IO.puts("Starting IP geolocation task for IP: #{ip}")
|
||||
|
||||
Task.start(fn ->
|
||||
try do
|
||||
get_ip_location(ip)
|
||||
rescue
|
||||
error ->
|
||||
IO.puts("Error in IP geolocation task: #{inspect(error)}")
|
||||
IO.puts("Stacktrace: #{inspect(__STACKTRACE__)}")
|
||||
send(self(), {:ip_location, @default_center})
|
||||
end
|
||||
end)
|
||||
else
|
||||
IO.puts("No IP address found, skipping geolocation")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp schedule_timers do
|
||||
Process.send_after(self(), :cleanup_old_packets, 60_000)
|
||||
Process.send_after(self(), :initialize_replay, 2000)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("bounds_changed", %{"bounds" => bounds}, socket) do
|
||||
handle_bounds_update(bounds, socket)
|
||||
|
|
@ -156,7 +149,8 @@ defmodule AprsWeb.MapLive.Index do
|
|||
def handle_event("toggle_replay", _params, socket) do
|
||||
if socket.assigns.replay_active do
|
||||
# Stop replay
|
||||
if socket.assigns.replay_timer_ref, do: Process.cancel_timer(socket.assigns.replay_timer_ref)
|
||||
if socket.assigns.replay_timer_ref,
|
||||
do: Process.cancel_timer(socket.assigns.replay_timer_ref)
|
||||
|
||||
# Clear historical packets from the map
|
||||
socket =
|
||||
|
|
@ -191,7 +185,9 @@ defmodule AprsWeb.MapLive.Index do
|
|||
{:noreply, assign(socket, replay_paused: false, replay_timer_ref: timer_ref)}
|
||||
else
|
||||
# Pause replay
|
||||
if socket.assigns.replay_timer_ref, do: Process.cancel_timer(socket.assigns.replay_timer_ref)
|
||||
if socket.assigns.replay_timer_ref,
|
||||
do: Process.cancel_timer(socket.assigns.replay_timer_ref)
|
||||
|
||||
{:noreply, assign(socket, replay_paused: true, replay_timer_ref: nil)}
|
||||
end
|
||||
else
|
||||
|
|
@ -259,6 +255,11 @@ defmodule AprsWeb.MapLive.Index do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("marker_clicked", %{"id" => id, "callsign" => callsign, "lat" => lat, "lng" => lng}, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp handle_bounds_update(bounds, socket) do
|
||||
# Update the map bounds from the client
|
||||
map_bounds = %{
|
||||
|
|
@ -384,9 +385,11 @@ defmodule AprsWeb.MapLive.Index do
|
|||
socket =
|
||||
if socket.assigns.map_ready do
|
||||
IO.puts("Map is ready, zooming to location immediately to lat=#{lat_float}, lng=#{lng_float}")
|
||||
|
||||
push_event(socket, "zoom_to_location", %{lat: lat_float, lng: lng_float, zoom: 12})
|
||||
else
|
||||
IO.puts("Map not ready yet, storing location lat=#{lat_float}, lng=#{lng_float} for later")
|
||||
|
||||
assign(socket, pending_geolocation: %{lat: lat_float, lng: lng_float})
|
||||
end
|
||||
|
||||
|
|
@ -430,7 +433,8 @@ defmodule AprsWeb.MapLive.Index do
|
|||
"#{sanitized_packet.base_callsign}#{if sanitized_packet.ssid, do: "-#{sanitized_packet.ssid}", else: ""}"
|
||||
|
||||
# Update visible packets tracking
|
||||
visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, sanitized_packet)
|
||||
visible_packets =
|
||||
Map.put(socket.assigns.visible_packets, callsign_key, sanitized_packet)
|
||||
|
||||
# Push the packet to the client-side JavaScript
|
||||
socket =
|
||||
|
|
@ -474,11 +478,15 @@ defmodule AprsWeb.MapLive.Index do
|
|||
packet_data =
|
||||
Map.merge(packet_data, %{
|
||||
"is_historical" => true,
|
||||
"timestamp" => if(Map.has_key?(packet, :received_at), do: DateTime.to_iso8601(packet.received_at))
|
||||
"timestamp" =>
|
||||
if(Map.has_key?(packet, :received_at),
|
||||
do: DateTime.to_iso8601(packet.received_at)
|
||||
)
|
||||
})
|
||||
|
||||
# Generate a unique key for this packet
|
||||
packet_id = "hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}"
|
||||
packet_id =
|
||||
"hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}"
|
||||
|
||||
# Update historical packets tracking
|
||||
new_historical_packets = Map.put(historical_packets, packet_id, packet)
|
||||
|
|
@ -952,7 +960,8 @@ defmodule AprsWeb.MapLive.Index do
|
|||
IO.puts("Response body: #{String.slice(body, 0, 200)}...")
|
||||
|
||||
case Jason.decode(body) do
|
||||
{:ok, %{"status" => "success", "lat" => lat, "lon" => lng}} when is_number(lat) and is_number(lng) ->
|
||||
{:ok, %{"status" => "success", "lat" => lat, "lon" => lng}}
|
||||
when is_number(lat) and is_number(lng) ->
|
||||
IO.puts("Valid coordinates found: lat=#{lat}, lng=#{lng}")
|
||||
|
||||
if lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do
|
||||
|
|
@ -998,40 +1007,39 @@ defmodule AprsWeb.MapLive.Index do
|
|||
|
||||
defp build_data_extended(nil), do: nil
|
||||
|
||||
defp build_data_extended(data_extended) do
|
||||
case data_extended do
|
||||
%MicE{} = mic_e ->
|
||||
# Convert MicE components to decimal degrees
|
||||
lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0
|
||||
lat = if mic_e.lat_direction == :south, do: -lat, else: lat
|
||||
defp build_data_extended(%MicE{} = mic_e), do: build_mice_data_extended(mic_e)
|
||||
defp build_data_extended(data_extended), do: build_map_data_extended(data_extended)
|
||||
|
||||
lng = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0
|
||||
lng = if mic_e.lon_direction == :west, do: -lng, else: lng
|
||||
defp build_mice_data_extended(mic_e) do
|
||||
lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0
|
||||
lat = if mic_e.lat_direction == :south, do: -lat, else: lat
|
||||
|
||||
# Validate coordinates are within valid ranges
|
||||
if lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 do
|
||||
%{
|
||||
"latitude" => lat,
|
||||
"longitude" => lng,
|
||||
"comment" => mic_e.message || "",
|
||||
"symbol_table_id" => "/",
|
||||
"symbol_code" => ">",
|
||||
"aprs_messaging" => false
|
||||
}
|
||||
end
|
||||
lng = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0
|
||||
lng = if mic_e.lon_direction == :west, do: -lng, else: lng
|
||||
|
||||
_ ->
|
||||
%{
|
||||
"latitude" => data_extended[:latitude],
|
||||
"longitude" => data_extended[:longitude],
|
||||
"comment" => data_extended[:comment] || "",
|
||||
"symbol_table_id" => data_extended[:symbol_table_id] || "/",
|
||||
"symbol_code" => data_extended[:symbol_code] || ">",
|
||||
"aprs_messaging" => data_extended[:aprs_messaging] || false
|
||||
}
|
||||
if lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 do
|
||||
%{
|
||||
"latitude" => lat,
|
||||
"longitude" => lng,
|
||||
"comment" => mic_e.message || "",
|
||||
"symbol_table_id" => "/",
|
||||
"symbol_code" => ">",
|
||||
"aprs_messaging" => false
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_map_data_extended(data_extended) do
|
||||
%{
|
||||
"latitude" => data_extended[:latitude],
|
||||
"longitude" => data_extended[:longitude],
|
||||
"comment" => data_extended[:comment] || "",
|
||||
"symbol_table_id" => data_extended[:symbol_table_id] || "/",
|
||||
"symbol_code" => data_extended[:symbol_code] || ">",
|
||||
"aprs_messaging" => data_extended[:aprs_messaging] || false
|
||||
}
|
||||
end
|
||||
|
||||
# Helper function to convert string or float to float
|
||||
defp to_float(value) when is_float(value), do: value
|
||||
defp to_float(value) when is_integer(value), do: value * 1.0
|
||||
|
|
|
|||
|
|
@ -1,94 +1,163 @@
|
|||
<.header>
|
||||
Packets for {@callsign}
|
||||
<:subtitle>
|
||||
Showing up to 100 packets (stored and live) for callsign {@callsign}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<%= if @error do %>
|
||||
<div class="bg-red-50 border border-red-200 rounded-md p-4 mb-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 my-8">
|
||||
<div class="mb-8">
|
||||
<.header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Packets for {@callsign}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Showing up to 100 packets (stored and live) for callsign {@callsign}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<.link navigate={~p"/"} class="text-sm text-blue-600 hover:text-blue-800">
|
||||
← Back to Map
|
||||
</.link>
|
||||
<.link navigate={~p"/packets"} class="text-sm text-blue-600 hover:text-blue-800">
|
||||
All Packets
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">Error</h3>
|
||||
<div class="mt-1 text-sm text-red-700">
|
||||
{@error}
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<%= if @error do %>
|
||||
<div class="mt-6 bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">Error</h3>
|
||||
<div class="mt-1 text-sm text-red-700">
|
||||
{@error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="mb-4">
|
||||
<.link
|
||||
navigate={~p"/#{String.downcase(@callsign)}"}
|
||||
class="text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
← View {@callsign} on Map
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<.table id="callsign-packets" rows={@all_packets}>
|
||||
<:col :let={packet} label="Time">
|
||||
<span class="text-sm text-gray-600">
|
||||
<%= case packet.received_at do %>
|
||||
<% %DateTime{} = dt -> %>
|
||||
{Calendar.strftime(dt, "%H:%M:%S")}
|
||||
<% dt when is_binary(dt) -> %>
|
||||
<%= case DateTime.from_iso8601(dt) do %>
|
||||
<% {:ok, parsed_dt, _} -> %>
|
||||
{Calendar.strftime(parsed_dt, "%H:%M:%S")}
|
||||
<% else %>
|
||||
<div class="mt-6 bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<.table
|
||||
id="callsign-packets"
|
||||
rows={@all_packets}
|
||||
class="min-w-full divide-y divide-gray-200"
|
||||
>
|
||||
<:col
|
||||
:let={packet}
|
||||
label="Time"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<span class="text-sm text-gray-600 font-mono">
|
||||
<%= case packet.received_at do %>
|
||||
<% %DateTime{} = dt -> %>
|
||||
{Calendar.strftime(dt, "%H:%M:%S")}
|
||||
<% dt when is_binary(dt) -> %>
|
||||
<%= case DateTime.from_iso8601(dt) do %>
|
||||
<% {:ok, parsed_dt, _} -> %>
|
||||
{Calendar.strftime(parsed_dt, "%H:%M:%S")}
|
||||
<% _ -> %>
|
||||
{dt}
|
||||
<% end %>
|
||||
<% _ -> %>
|
||||
{dt}
|
||||
N/A
|
||||
<% end %>
|
||||
<% _ -> %>
|
||||
N/A
|
||||
<% end %>
|
||||
</span>
|
||||
</:col>
|
||||
<:col :let={packet} label="Sender">{packet.sender}</:col>
|
||||
<:col :let={packet} label="SSID">{packet.ssid}</:col>
|
||||
<:col :let={packet} label="Data Type">{packet.data_type}</:col>
|
||||
<:col :let={packet} label="Destination">{packet.destination}</:col>
|
||||
<:col :let={packet} label="Information">
|
||||
<span class="text-sm">
|
||||
<%= if String.length(packet.information_field || "") > 50 do %>
|
||||
<span title={packet.information_field}>
|
||||
{String.slice(packet.information_field, 0, 50)}...
|
||||
</span>
|
||||
<% else %>
|
||||
{packet.information_field}
|
||||
<% end %>
|
||||
</span>
|
||||
</:col>
|
||||
<:col :let={packet} label="Path">
|
||||
<span class="text-xs text-gray-500">
|
||||
{packet.path}
|
||||
</span>
|
||||
</:col>
|
||||
</.table>
|
||||
</:col>
|
||||
<:col
|
||||
:let={packet}
|
||||
label="Sender"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<span class="text-sm text-gray-900 font-medium">{packet.sender}</span>
|
||||
</:col>
|
||||
<:col
|
||||
:let={packet}
|
||||
label="SSID"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<span class="text-sm text-gray-900">{packet.ssid}</span>
|
||||
</:col>
|
||||
<:col
|
||||
:let={packet}
|
||||
label="Data Type"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{packet.data_type}
|
||||
</span>
|
||||
</:col>
|
||||
<:col
|
||||
:let={packet}
|
||||
label="Destination"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<span class="text-sm text-gray-900">{packet.destination}</span>
|
||||
</:col>
|
||||
<:col
|
||||
:let={packet}
|
||||
label="Information"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<span class="text-sm text-gray-900 font-mono">
|
||||
<%= if String.length(packet.information_field || "") > 50 do %>
|
||||
<span title={packet.information_field}>
|
||||
{String.slice(packet.information_field, 0, 50)}...
|
||||
</span>
|
||||
<% else %>
|
||||
{packet.information_field}
|
||||
<% end %>
|
||||
</span>
|
||||
</:col>
|
||||
<:col
|
||||
:let={packet}
|
||||
label="Path"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<span class="text-xs text-gray-500 font-mono">
|
||||
{packet.path}
|
||||
</span>
|
||||
</:col>
|
||||
</.table>
|
||||
</div>
|
||||
|
||||
<%= if length(@all_packets) == 0 do %>
|
||||
<div class="text-center py-8">
|
||||
<p class="text-gray-500">No packets found for {@callsign}</p>
|
||||
<p class="text-sm text-gray-400 mt-2">
|
||||
Packets will appear here as they are received live or if there are stored packets for this callsign.
|
||||
</p>
|
||||
<%= if length(@all_packets) == 0 do %>
|
||||
<div class="mt-8 text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100">
|
||||
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No packets found for {@callsign}</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Packets will appear here as they are received live or if there are stored packets for this callsign.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-4 px-4 py-3 bg-gray-50 border-t border-gray-200 rounded-b-lg">
|
||||
<div class="flex items-center justify-between text-sm text-gray-500">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span>
|
||||
Live packets: <span class="font-medium text-gray-900">{length(@live_packets)}</span>
|
||||
</span>
|
||||
<span>
|
||||
Stored packets: <span class="font-medium text-gray-900">{length(@packets)}</span>
|
||||
</span>
|
||||
<span>
|
||||
Total: <span class="font-medium text-gray-900">{length(@all_packets)}/100</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
<p>
|
||||
Live packets: {length(@live_packets)} |
|
||||
Stored packets: {length(@packets)} |
|
||||
Total: {length(@all_packets)}/100
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,100 @@
|
|||
<.header>
|
||||
Packets
|
||||
</.header>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 my-8">
|
||||
<div class="mb-8">
|
||||
<.header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">APRS Packets</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Live and recent APRS packets from the network</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<.link navigate={~p"/"} class="text-sm text-blue-600 hover:text-blue-800">
|
||||
← Back to Map
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<.table id="packets" rows={@packets}>
|
||||
<:col :let={packet} label="sender">
|
||||
<.link
|
||||
navigate={~p"/packets/#{packet.base_callsign}"}
|
||||
class="text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
{packet.sender}
|
||||
</.link>
|
||||
</:col>
|
||||
<:col :let={packet} label="ssid">{packet.ssid}</:col>
|
||||
<:col :let={packet} label="base_callsign">
|
||||
<.link
|
||||
navigate={~p"/packets/#{packet.base_callsign}"}
|
||||
class="text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
{packet.base_callsign}
|
||||
</.link>
|
||||
</:col>
|
||||
<:col :let={packet} label="data_type">{packet.data_type}</:col>
|
||||
<:col :let={packet} label="destination">{packet.destination}</:col>
|
||||
<:col :let={packet} label="information_field">{packet.information_field}</:col>
|
||||
<:col :let={packet} label="path">{packet.path}</:col>
|
||||
</.table>
|
||||
<div class="mt-6 bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<.table id="packets" rows={@packets} class="min-w-full divide-y divide-gray-200">
|
||||
<:col
|
||||
:let={packet}
|
||||
label="Sender"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<.link
|
||||
navigate={~p"/packets/#{packet.base_callsign}"}
|
||||
class="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
{packet.sender}
|
||||
</.link>
|
||||
</:col>
|
||||
<:col
|
||||
:let={packet}
|
||||
label="SSID"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<span class="text-sm text-gray-900">{packet.ssid}</span>
|
||||
</:col>
|
||||
<:col
|
||||
:let={packet}
|
||||
label="Base Callsign"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<.link
|
||||
navigate={~p"/packets/#{packet.base_callsign}"}
|
||||
class="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
{packet.base_callsign}
|
||||
</.link>
|
||||
</:col>
|
||||
<:col
|
||||
:let={packet}
|
||||
label="Data Type"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{packet.data_type}
|
||||
</span>
|
||||
</:col>
|
||||
<:col
|
||||
:let={packet}
|
||||
label="Destination"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<span class="text-sm text-gray-900">{packet.destination}</span>
|
||||
</:col>
|
||||
<:col
|
||||
:let={packet}
|
||||
label="Information"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<span class="text-sm text-gray-900 font-mono">{packet.information_field}</span>
|
||||
</:col>
|
||||
<:col
|
||||
:let={packet}
|
||||
label="Path"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<span class="text-xs text-gray-500 font-mono">{packet.path}</span>
|
||||
</:col>
|
||||
</.table>
|
||||
</div>
|
||||
|
||||
<%= if length(@packets) == 0 do %>
|
||||
<div class="mt-8 text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100">
|
||||
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No packets</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Packets will appear here as they are received.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
|||
761
lib/parser.ex
761
lib/parser.ex
|
|
@ -43,9 +43,9 @@ defmodule Parser do
|
|||
rescue
|
||||
error ->
|
||||
Logger.debug("PARSE ERROR: #{inspect(error)} for message: #{message}")
|
||||
{:ok, file} = File.open("./badpackets.txt", [:append])
|
||||
IO.binwrite(file, message <> "\n\n")
|
||||
File.close(file)
|
||||
# {:ok, file} = File.open("./badpackets.txt", [:append])
|
||||
# IO.binwrite(file, message <> "\n\n")
|
||||
# File.close(file)
|
||||
{:error, :invalid_packet}
|
||||
end
|
||||
|
||||
|
|
@ -90,37 +90,14 @@ defmodule Parser do
|
|||
byte_size(callsign) == 0 ->
|
||||
{:error, "Empty callsign"}
|
||||
|
||||
byte_size(callsign) > 15 ->
|
||||
{:error, "Callsign too long"}
|
||||
|
||||
String.contains?(callsign, "-") ->
|
||||
case String.split(callsign, "-") do
|
||||
[base, ssid] when byte_size(base) > 0 and byte_size(base) <= 6 ->
|
||||
case validate_ssid(ssid) do
|
||||
{:ok, valid_ssid} -> {:ok, [base, valid_ssid]}
|
||||
{:error, _} -> {:error, "Invalid SSID"}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:error, "Invalid callsign-SSID format"}
|
||||
[base, ssid] -> {:ok, [base, ssid]}
|
||||
_ -> {:ok, [callsign, "0"]}
|
||||
end
|
||||
|
||||
byte_size(callsign) <= 6 ->
|
||||
{:ok, [callsign, "0"]}
|
||||
|
||||
true ->
|
||||
{:error, "Invalid callsign"}
|
||||
end
|
||||
end
|
||||
|
||||
# Validate SSID (0-15)
|
||||
defp validate_ssid(ssid) do
|
||||
case Integer.parse(ssid) do
|
||||
{num, ""} when num >= 0 and num <= 15 ->
|
||||
{:ok, Integer.to_string(num)}
|
||||
|
||||
_ ->
|
||||
{:error, "SSID must be 0-15"}
|
||||
{:ok, [callsign, "0"]}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -158,46 +135,70 @@ defmodule Parser do
|
|||
|
||||
def parse_data(:mic_e, destination, data), do: parse_mic_e(destination, data)
|
||||
def parse_data(:mic_e_old, destination, data), do: parse_mic_e(destination, data)
|
||||
def parse_data(:position, _destination, data), do: parse_position_without_timestamp(false, data)
|
||||
|
||||
def parse_data(:position_with_message, _destination, data), do: parse_position_without_timestamp(true, data)
|
||||
def parse_data(:position, _destination, data) do
|
||||
case data do
|
||||
<<"/", _::binary>> ->
|
||||
result = parse_position_without_timestamp(false, data)
|
||||
%{result | data_type: :position}
|
||||
|
||||
def parse_data(:timestamped_position, _destination, data), do: parse_position_with_timestamp(false, data)
|
||||
|
||||
def parse_data(
|
||||
:timestamped_position_with_message,
|
||||
_destination,
|
||||
<<_dti::binary-size(1), date_time_position::binary-size(25), "_", weather_report::binary>>
|
||||
) do
|
||||
parse_position_with_datetime_and_weather(true, date_time_position, weather_report)
|
||||
_ ->
|
||||
result = parse_position_without_timestamp(false, data)
|
||||
%{result | data_type: :position}
|
||||
end
|
||||
end
|
||||
|
||||
def parse_data(:timestamped_position_with_message, _destination, data), do: parse_position_with_timestamp(true, data)
|
||||
def parse_data(:position_with_message, _destination, data) do
|
||||
result = parse_position_without_timestamp(true, data)
|
||||
%{result | data_type: :position}
|
||||
end
|
||||
|
||||
def parse_data(:message, _destination, <<":", addressee::binary-size(9), ":", message_text::binary>>) do
|
||||
# Aprs messages can have an optional message number tacked onto the end
|
||||
# for the purposes of acknowledging message receipt.
|
||||
# The sender tacks the message number onto the end of the message,
|
||||
# and the receiving station is supposed to respond back with an
|
||||
# acknowledgement of that message number.
|
||||
# Example
|
||||
# Sender: Hello world{123
|
||||
# Receiver: ack123
|
||||
# Special thanks to Jeff Smith(https://github.com/electricshaman) for the regex
|
||||
regex = ~r/^(?<message>.*?)(?:{(?<message_number>\w+))?$/i
|
||||
result = find_matches(regex, message_text)
|
||||
def parse_data(:timestamped_position, _destination, data) do
|
||||
case data do
|
||||
<<"/", _::binary>> ->
|
||||
%{
|
||||
data_type: :timestamped_position_error,
|
||||
error: "Compressed position not supported in timestamped position"
|
||||
}
|
||||
|
||||
message_text =
|
||||
case result["message"] do
|
||||
nil -> ""
|
||||
m -> String.trim(m)
|
||||
end
|
||||
_ ->
|
||||
parse_position_with_timestamp(false, data)
|
||||
end
|
||||
end
|
||||
|
||||
%{
|
||||
to: String.trim(addressee),
|
||||
message_text: message_text,
|
||||
message_number: result["message_number"]
|
||||
}
|
||||
def parse_data(:timestamped_position_with_message, _destination, data) do
|
||||
case data do
|
||||
<<"/", _::binary>> ->
|
||||
%{
|
||||
data_type: :timestamped_position_error,
|
||||
error: "Compressed position not supported in timestamped position"
|
||||
}
|
||||
|
||||
_ ->
|
||||
parse_position_with_timestamp(true, data)
|
||||
end
|
||||
end
|
||||
|
||||
def parse_data(:message, _destination, data) do
|
||||
case Regex.run(~r/^:([^:]+):(.+?)(?:\{(\d+)\})?$/, data) do
|
||||
[_, addressee, message_text, message_number] ->
|
||||
%{
|
||||
data_type: :message,
|
||||
addressee: String.trim(addressee),
|
||||
message_text: String.trim(message_text),
|
||||
message_number: message_number
|
||||
}
|
||||
|
||||
[_, addressee, message_text] ->
|
||||
%{
|
||||
data_type: :message,
|
||||
addressee: String.trim(addressee),
|
||||
message_text: String.trim(message_text)
|
||||
}
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def parse_data(:status, _destination, data), do: parse_status(data)
|
||||
|
|
@ -216,31 +217,51 @@ defmodule Parser do
|
|||
def parse_data(_type, _destination, _data), do: nil
|
||||
|
||||
def parse_position_with_datetime_and_weather(aprs_messaging?, date_time_position_data, weather_report) do
|
||||
<<time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9)>> =
|
||||
date_time_position_data
|
||||
case date_time_position_data do
|
||||
<<time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9)>> ->
|
||||
%{latitude: lat, longitude: lon} = Position.from_aprs(latitude, longitude)
|
||||
|
||||
# position = Parser.Types.Position.from_aprs(latitude, longitude)
|
||||
%{latitude: lat, longitude: lon} = Position.from_aprs(latitude, longitude)
|
||||
weather_data = parse_weather_data(weather_report)
|
||||
|
||||
%{
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
timestamp: time,
|
||||
symbol_table_id: sym_table_id,
|
||||
symbol_code: "_",
|
||||
weather: weather_report,
|
||||
data_type: :position_with_datetime_and_weather,
|
||||
aprs_messaging?: aprs_messaging?
|
||||
}
|
||||
%{
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
timestamp: time,
|
||||
symbol_table_id: sym_table_id,
|
||||
symbol_code: "_",
|
||||
weather: weather_data,
|
||||
data_type: :position_with_datetime_and_weather,
|
||||
aprs_messaging?: aprs_messaging?
|
||||
}
|
||||
|
||||
_ ->
|
||||
weather_data = parse_weather_data(weather_report)
|
||||
|
||||
%{
|
||||
latitude: nil,
|
||||
longitude: nil,
|
||||
timestamp: nil,
|
||||
symbol_table_id: nil,
|
||||
symbol_code: nil,
|
||||
weather: weather_data,
|
||||
data_type: :position_with_datetime_and_weather,
|
||||
aprs_messaging?: aprs_messaging?
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def decode_compressed_position(
|
||||
<<"/", latitude::binary-size(4), longitude::binary-size(4), _symbol::binary-size(1), _cs::binary-size(2),
|
||||
<<"/", latitude::binary-size(4), longitude::binary-size(4), symbol_code::binary-size(1), _cs::binary-size(2),
|
||||
_compression_type::binary-size(2), _rest::binary>>
|
||||
) do
|
||||
lat = convert_to_base91(latitude)
|
||||
lon = convert_to_base91(longitude)
|
||||
[:ok, lat, lon]
|
||||
|
||||
%{
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
symbol_code: symbol_code
|
||||
}
|
||||
end
|
||||
|
||||
def convert_to_base91(<<value::binary-size(4)>>) do
|
||||
|
|
@ -248,141 +269,55 @@ defmodule Parser do
|
|||
(v1 - 33) * 91 * 91 * 91 + (v2 - 33) * 91 * 91 + (v3 - 33) * 91 + v4
|
||||
end
|
||||
|
||||
def parse_position_without_timestamp(_aprs_messaging?, <<"!!", rest::binary>> = message) do
|
||||
# this is an ultimeter weather station. need to parse its weird format
|
||||
# {:ok, file} = File.open("badpackets.txt")
|
||||
# IO.puts(file, message)
|
||||
# File.close(file)
|
||||
def parse_position_without_timestamp(aprs_messaging?, position_data) do
|
||||
case position_data do
|
||||
<<latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9)>> ->
|
||||
%{latitude: lat, longitude: lon} = Position.from_aprs(latitude, longitude)
|
||||
|
||||
# a = "0000000001FF000427C70002CCD30001026E003A050F00040000"
|
||||
|
||||
[
|
||||
wind_speed,
|
||||
wind_direction,
|
||||
temp,
|
||||
rain_long_term_total,
|
||||
barrometer,
|
||||
barrometer_delta_value,
|
||||
barrometer_corr_factor_lsw,
|
||||
barrometer_corr_factor_msw,
|
||||
humidity,
|
||||
_day_of_year,
|
||||
_minute_of_day,
|
||||
today_rain_total,
|
||||
wind_speed_avg
|
||||
] =
|
||||
rest
|
||||
|> String.codepoints()
|
||||
|> Enum.chunk_every(4)
|
||||
|> Enum.map(&Enum.join/1)
|
||||
|> Enum.map(&hex_decode(&1))
|
||||
|
||||
%{
|
||||
wind_speed: Convert.wind(wind_speed, :ultimeter, :mph),
|
||||
wind_direction: wind_direction,
|
||||
temp_f: Convert.temp(temp, :ultimeter, :f),
|
||||
rain_long_term_total: rain_long_term_total,
|
||||
barrometer: barrometer,
|
||||
barrometer_delta_value: barrometer_delta_value,
|
||||
barrometer_corr_factor_lsw: barrometer_corr_factor_lsw,
|
||||
barrometer_corr_factor_msw: barrometer_corr_factor_msw,
|
||||
humidity: humidity,
|
||||
today_rain_total: today_rain_total,
|
||||
wind_speed_avg: wind_speed_avg
|
||||
}
|
||||
|
||||
Logger.debug("TODO: PARSE ULTIMETER DATA: " <> message)
|
||||
end
|
||||
|
||||
def parse_position_without_timestamp(
|
||||
aprs_messaging?,
|
||||
<<_dti::binary-size(1), latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9),
|
||||
symbol_code::binary-size(1), comment::binary>>
|
||||
) do
|
||||
case validate_position_data(latitude, longitude) do
|
||||
{:ok, {lat, lon}} ->
|
||||
%{
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
timestamp: nil,
|
||||
symbol_table_id: sym_table_id,
|
||||
symbol_code: symbol_code,
|
||||
comment: comment,
|
||||
symbol_code: "_",
|
||||
data_type: :position,
|
||||
aprs_messaging?: aprs_messaging?
|
||||
aprs_messaging?: aprs_messaging?,
|
||||
compressed?: false
|
||||
}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Invalid position data: #{reason}")
|
||||
<<"/", latitude_compressed::binary-size(4), longitude_compressed::binary-size(4), symbol_code::binary-size(1),
|
||||
cs::binary-size(2), compression_type::binary-size(1), comment::binary>> ->
|
||||
converted_lat = convert_compressed_lat(latitude_compressed)
|
||||
converted_lon = convert_compressed_lon(longitude_compressed)
|
||||
compressed_cs = convert_compressed_cs(cs)
|
||||
|
||||
base_data = %{
|
||||
latitude: converted_lat,
|
||||
longitude: converted_lon,
|
||||
symbol_table_id: "/",
|
||||
symbol_code: symbol_code,
|
||||
comment: comment,
|
||||
position_format: :compressed,
|
||||
compression_type: compression_type,
|
||||
data_type: :position,
|
||||
compressed?: true
|
||||
}
|
||||
|
||||
Map.merge(base_data, compressed_cs)
|
||||
|
||||
_ ->
|
||||
%{
|
||||
latitude: nil,
|
||||
longitude: nil,
|
||||
symbol_table_id: sym_table_id,
|
||||
symbol_code: symbol_code,
|
||||
comment: comment,
|
||||
data_type: :invalid_position,
|
||||
timestamp: nil,
|
||||
symbol_table_id: nil,
|
||||
symbol_code: nil,
|
||||
data_type: :malformed_position,
|
||||
aprs_messaging?: aprs_messaging?,
|
||||
error: reason
|
||||
compressed?: false,
|
||||
comment: String.trim(position_data)
|
||||
}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error(Exception.format(:error, e, __STACKTRACE__))
|
||||
|
||||
%{
|
||||
latitude: nil,
|
||||
longitude: nil,
|
||||
data_type: :parse_error,
|
||||
error: "Position parsing failed"
|
||||
}
|
||||
end
|
||||
|
||||
def parse_position_without_timestamp(
|
||||
aprs_messaging?,
|
||||
<<_dti::binary-size(1), "/", latitude::binary-size(4), longitude::binary-size(4), symbol_code::binary-size(1),
|
||||
cs::binary-size(2), _compression_type::binary-size(1), comment::binary>> = _message
|
||||
) do
|
||||
# Parse compressed latitude and longitude
|
||||
converted_lat = convert_compressed_lat(latitude)
|
||||
converted_lon = convert_compressed_lon(longitude)
|
||||
position = Position.from_decimal(converted_lat, converted_lon)
|
||||
|
||||
# Parse compressed course/speed or range data
|
||||
compressed_cs = convert_compressed_cs(cs)
|
||||
|
||||
# In compressed format, the symbol is a single character that represents both
|
||||
# the symbol table and symbol code. For proper APRS compatibility, we would
|
||||
# need to decode this, but for now we'll treat it as the symbol code.
|
||||
result = %{
|
||||
latitude: converted_lat,
|
||||
longitude: converted_lon,
|
||||
position: position,
|
||||
# Compressed format uses "/" as default table
|
||||
symbol_table_id: "/",
|
||||
symbol_code: symbol_code,
|
||||
comment: comment,
|
||||
data_type: :position,
|
||||
aprs_messaging?: aprs_messaging?
|
||||
}
|
||||
|
||||
# Add course/speed or range information if available
|
||||
Map.merge(result, compressed_cs)
|
||||
end
|
||||
|
||||
# Catch-all pattern for malformed position packets
|
||||
def parse_position_without_timestamp(aprs_messaging?, <<_dti::binary-size(1), rest::binary>> = data) do
|
||||
Logger.warning("Malformed position packet: #{inspect(data)}")
|
||||
|
||||
%{
|
||||
latitude: nil,
|
||||
longitude: nil,
|
||||
symbol_table_id: nil,
|
||||
symbol_code: nil,
|
||||
comment: rest,
|
||||
data_type: :malformed_position,
|
||||
aprs_messaging?: aprs_messaging?,
|
||||
raw_data: data
|
||||
}
|
||||
end
|
||||
|
||||
def parse_position_with_timestamp(
|
||||
|
|
@ -403,7 +338,8 @@ defmodule Parser do
|
|||
symbol_code: symbol_code,
|
||||
comment: comment,
|
||||
data_type: :position,
|
||||
aprs_messaging?: aprs_messaging?
|
||||
aprs_messaging?: aprs_messaging?,
|
||||
compressed?: false
|
||||
}
|
||||
|
||||
{:error, reason} ->
|
||||
|
|
@ -416,7 +352,7 @@ defmodule Parser do
|
|||
symbol_table_id: sym_table_id,
|
||||
symbol_code: symbol_code,
|
||||
comment: comment,
|
||||
data_type: :invalid_position,
|
||||
data_type: :timestamped_position_error,
|
||||
aprs_messaging?: aprs_messaging?,
|
||||
error: reason
|
||||
}
|
||||
|
|
@ -428,11 +364,26 @@ defmodule Parser do
|
|||
%{
|
||||
latitude: nil,
|
||||
longitude: nil,
|
||||
data_type: :parse_error,
|
||||
data_type: :timestamped_position_error,
|
||||
error: "Timestamped position parsing failed"
|
||||
}
|
||||
end
|
||||
|
||||
def parse_position_with_timestamp(_aprs_messaging?, <<"/", _::binary>>) do
|
||||
%{
|
||||
data_type: :timestamped_position_error,
|
||||
error: "Compressed position not supported in timestamped position"
|
||||
}
|
||||
end
|
||||
|
||||
def parse_position_with_timestamp(_aprs_messaging?, data) do
|
||||
%{
|
||||
data_type: :timestamped_position_error,
|
||||
error: "Invalid timestamped position format",
|
||||
raw_data: data
|
||||
}
|
||||
end
|
||||
|
||||
def parse_mic_e(destination_field, information_field) do
|
||||
# Logger.debug("MIC-E: " <> destination_field <> " :: " <> information_field)
|
||||
# Mic-E is kind of a nutty compression scheme, APRS packs additional
|
||||
|
|
@ -466,6 +417,29 @@ defmodule Parser do
|
|||
}
|
||||
end
|
||||
|
||||
def parse_mic_e(data) do
|
||||
case data do
|
||||
<<dti::binary-size(1), latitude::binary-size(6), longitude::binary-size(7), symbol_code::binary-size(1),
|
||||
speed::binary-size(1), course::binary-size(1), symbol_table_id::binary-size(1), rest::binary>> ->
|
||||
%{latitude: lat, longitude: lon} = Position.from_aprs(latitude, longitude)
|
||||
|
||||
%{
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
symbol_table_id: symbol_table_id,
|
||||
symbol_code: symbol_code,
|
||||
speed: speed,
|
||||
course: course,
|
||||
dti: dti,
|
||||
comment: rest,
|
||||
data_type: :mic_e
|
||||
}
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def parse_mic_e_digit(<<c>>) when c in ?0..?9, do: [c - ?0, 0, nil]
|
||||
def parse_mic_e_digit(<<c>>) when c in ?A..?J, do: [c - ?A, 1, :custom]
|
||||
def parse_mic_e_digit(<<c>>) when c in ?P..?Y, do: [c - ?P, 1, :standard]
|
||||
|
|
@ -618,7 +592,6 @@ defmodule Parser do
|
|||
end
|
||||
|
||||
def parse_manufacturer(" ", _s2, _s3), do: "Original MIC-E"
|
||||
def parse_manufacturer(">", _s2, "="), do: "Kenwood TH-D72"
|
||||
def parse_manufacturer(">", _s2, "^"), do: "Kenwood TH-D74"
|
||||
def parse_manufacturer(">", _s2, _s3), do: "Kenwood TH-D74A"
|
||||
def parse_manufacturer("]", _s2, "="), do: "Kenwood DM-710"
|
||||
|
|
@ -642,7 +615,7 @@ defmodule Parser do
|
|||
def parse_manufacturer(_s1, "^", _s3), do: "HinzTec anyfrog"
|
||||
def parse_manufacturer(_s1, "*", _s3), do: "APOZxx www.KissOZ.dk Tracker. OZ1EKD and OZ7HVO"
|
||||
def parse_manufacturer(_s1, "~", _s3), do: "Other"
|
||||
def parse_manufacturer(_symbol1, _symbol2, _symbol3), do: :unknown_manufacturer
|
||||
def parse_manufacturer(_symbol1, _symbol2, _symbol3), do: "Unknown"
|
||||
|
||||
defp find_matches(regex, text) do
|
||||
case Regex.names(regex) do
|
||||
|
|
@ -658,15 +631,6 @@ defmodule Parser do
|
|||
end
|
||||
end
|
||||
|
||||
defp hex_decode(input) do
|
||||
{result, ""} = Integer.parse(input, 16)
|
||||
result
|
||||
end
|
||||
|
||||
# defp convert_ultimeter_humidity(hum) do
|
||||
# hum * 10
|
||||
# end
|
||||
|
||||
def convert_compressed_lat(lat) do
|
||||
[l1, l2, l3, l4] = to_charlist(lat)
|
||||
90 - ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 380_926
|
||||
|
|
@ -698,12 +662,7 @@ defmodule Parser do
|
|||
}
|
||||
|
||||
_ ->
|
||||
IO.inspect(cs, label: "not parsable")
|
||||
|
||||
%{
|
||||
course: 0,
|
||||
speed: 0
|
||||
}
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -883,141 +842,122 @@ defmodule Parser do
|
|||
end
|
||||
|
||||
# Comprehensive weather data parsing
|
||||
defp parse_weather_data(weather_string) do
|
||||
%{}
|
||||
|> parse_wind_data(weather_string)
|
||||
|> parse_temperature_data(weather_string)
|
||||
|> parse_rain_data(weather_string)
|
||||
|> parse_humidity_data(weather_string)
|
||||
|> parse_barometric_data(weather_string)
|
||||
|> parse_snow_data(weather_string)
|
||||
|> parse_other_weather_data(weather_string)
|
||||
|> Map.put(:raw_weather_data, weather_string)
|
||||
defp parse_weather_data(weather_data) do
|
||||
# Extract timestamp if present
|
||||
timestamp = extract_timestamp(weather_data)
|
||||
weather_data = remove_timestamp(weather_data)
|
||||
|
||||
# Parse each weather component
|
||||
weather_values = %{
|
||||
wind_direction: parse_wind_direction(weather_data),
|
||||
wind_speed: parse_wind_speed(weather_data),
|
||||
wind_gust: parse_wind_gust(weather_data),
|
||||
temperature: parse_temperature(weather_data),
|
||||
rain_1h: parse_rainfall_1h(weather_data),
|
||||
rain_24h: parse_rainfall_24h(weather_data),
|
||||
rain_since_midnight: parse_rainfall_since_midnight(weather_data),
|
||||
humidity: parse_humidity(weather_data),
|
||||
pressure: parse_pressure(weather_data),
|
||||
luminosity: parse_luminosity(weather_data),
|
||||
snow: parse_snow(weather_data)
|
||||
}
|
||||
|
||||
# Build base result map
|
||||
result = %{
|
||||
timestamp: timestamp,
|
||||
data_type: :weather,
|
||||
raw_weather_data: weather_data
|
||||
}
|
||||
|
||||
# Add non-nil weather values to result
|
||||
Enum.reduce(weather_values, result, fn {key, value}, acc ->
|
||||
if value == nil, do: acc, else: Map.put(acc, key, value)
|
||||
end)
|
||||
end
|
||||
|
||||
# Parse wind direction and speed (ddd/sss format)
|
||||
defp parse_wind_data(data, weather_string) do
|
||||
case Regex.run(~r/(\d{3})\/(\d{3})/, weather_string) do
|
||||
[_, direction, speed] ->
|
||||
data
|
||||
|> Map.put(:wind_direction, String.to_integer(direction))
|
||||
|> Map.put(:wind_speed, String.to_integer(speed))
|
||||
|
||||
_ ->
|
||||
data
|
||||
defp parse_temperature(weather_data) do
|
||||
case Regex.run(~r/t(-?\d{3})/, weather_data) do
|
||||
[_, temp] -> String.to_integer(temp)
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Parse temperature (tTTT format, in Fahrenheit)
|
||||
defp parse_temperature_data(data, weather_string) do
|
||||
case Regex.run(~r/t(-?\d{3})/, weather_string) do
|
||||
[_, temp] ->
|
||||
temp_f = String.to_integer(temp)
|
||||
temp_c = (temp_f - 32) * 5 / 9
|
||||
|
||||
data
|
||||
|> Map.put(:temperature_f, temp_f)
|
||||
|> Map.put(:temperature_c, Float.round(temp_c, 1))
|
||||
|
||||
_ ->
|
||||
data
|
||||
defp parse_wind_direction(weather_data) do
|
||||
case Regex.run(~r/(\d{3})\//, weather_data) do
|
||||
[_, direction] -> String.to_integer(direction)
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Parse rainfall data
|
||||
defp parse_rain_data(data, weather_string) do
|
||||
data
|
||||
|> parse_rain_field(weather_string, ~r/r(\d{3})/, :rain_1h)
|
||||
|> parse_rain_field(weather_string, ~r/p(\d{3})/, :rain_24h)
|
||||
|> parse_rain_field(weather_string, ~r/P(\d{3})/, :rain_today)
|
||||
end
|
||||
|
||||
defp parse_rain_field(data, weather_string, regex, field) do
|
||||
case Regex.run(regex, weather_string) do
|
||||
[_, rain] ->
|
||||
rain_hundredths = String.to_integer(rain)
|
||||
rain_inches = rain_hundredths / 100.0
|
||||
Map.put(data, field, rain_inches)
|
||||
|
||||
_ ->
|
||||
data
|
||||
defp parse_wind_speed(weather_data) do
|
||||
case Regex.run(~r/\/(\d{3})/, weather_data) do
|
||||
[_, speed] -> String.to_integer(speed)
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Parse humidity (hHH format)
|
||||
defp parse_humidity_data(data, weather_string) do
|
||||
case Regex.run(~r/h(\d{2})/, weather_string) do
|
||||
defp parse_wind_gust(weather_data) do
|
||||
case Regex.run(~r/g(\d{3})/, weather_data) do
|
||||
[_, gust] -> String.to_integer(gust)
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_rainfall_1h(weather_data) do
|
||||
case Regex.run(~r/r(\d{3})/, weather_data) do
|
||||
[_, rain] -> String.to_integer(rain)
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_rainfall_24h(weather_data) do
|
||||
case Regex.run(~r/p(\d{3})/, weather_data) do
|
||||
[_, rain] -> String.to_integer(rain)
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_rainfall_since_midnight(weather_data) do
|
||||
case Regex.run(~r/P(\d{3})/, weather_data) do
|
||||
[_, rain] -> String.to_integer(rain)
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_humidity(weather_data) do
|
||||
case Regex.run(~r/h(\d{2})/, weather_data) do
|
||||
[_, humidity] ->
|
||||
humidity_val = String.to_integer(humidity)
|
||||
# Handle special case where 00 means 100%
|
||||
humidity_percent = if humidity_val == 0, do: 100, else: humidity_val
|
||||
Map.put(data, :humidity, humidity_percent)
|
||||
val = String.to_integer(humidity)
|
||||
if val == 0, do: 100, else: val
|
||||
|
||||
_ ->
|
||||
data
|
||||
nil ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Parse barometric pressure (bBBBBB format, in tenths of millibars)
|
||||
defp parse_barometric_data(data, weather_string) do
|
||||
case Regex.run(~r/b(\d{5})/, weather_string) do
|
||||
[_, pressure] ->
|
||||
pressure_tenths_mb = String.to_integer(pressure)
|
||||
pressure_mb = pressure_tenths_mb / 10.0
|
||||
pressure_inhg = pressure_mb * 0.02953
|
||||
|
||||
data
|
||||
|> Map.put(:barometric_pressure_mb, pressure_mb)
|
||||
|> Map.put(:barometric_pressure_inhg, Float.round(pressure_inhg, 2))
|
||||
|
||||
_ ->
|
||||
data
|
||||
defp parse_pressure(weather_data) do
|
||||
case Regex.run(~r/b(\d{5})/, weather_data) do
|
||||
[_, pressure] -> String.to_integer(pressure) / 10.0
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Parse snow data (sSS format, in inches)
|
||||
defp parse_snow_data(data, weather_string) do
|
||||
case Regex.run(~r/s(\d{3})/, weather_string) do
|
||||
[_, snow] ->
|
||||
snow_inches = String.to_integer(snow)
|
||||
Map.put(data, :snow_24h, snow_inches)
|
||||
|
||||
_ ->
|
||||
data
|
||||
defp parse_luminosity(weather_data) do
|
||||
case Regex.run(~r/[lL](\d{3})/, weather_data) do
|
||||
[_, luminosity] -> String.to_integer(luminosity)
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Parse other weather data (luminosity, etc.)
|
||||
defp parse_other_weather_data(data, weather_string) do
|
||||
data
|
||||
|> parse_luminosity(weather_string)
|
||||
|> parse_wind_gust(weather_string)
|
||||
end
|
||||
|
||||
# Parse luminosity (LLLLL format)
|
||||
defp parse_luminosity(data, weather_string) do
|
||||
case Regex.run(~r/L(\d{3})/, weather_string) do
|
||||
[_, luminosity] ->
|
||||
Map.put(data, :luminosity, String.to_integer(luminosity))
|
||||
|
||||
_ ->
|
||||
data
|
||||
end
|
||||
end
|
||||
|
||||
# Parse wind gust (gGGG format)
|
||||
defp parse_wind_gust(data, weather_string) do
|
||||
case Regex.run(~r/g(\d{3})/, weather_string) do
|
||||
[_, gust] ->
|
||||
Map.put(data, :wind_gust, String.to_integer(gust))
|
||||
|
||||
_ ->
|
||||
data
|
||||
defp parse_snow(weather_data) do
|
||||
case Regex.run(~r/s(\d{3})/, weather_data) do
|
||||
[_, snow] -> String.to_integer(snow)
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Telemetry parsing
|
||||
def parse_telemetry(<<"T", rest::binary>>) do
|
||||
def parse_telemetry(<<"T#", rest::binary>>) do
|
||||
case String.split(rest, ",") do
|
||||
[seq | [_ | _] = values] ->
|
||||
analog_values = Enum.take(values, 5)
|
||||
|
|
@ -1041,22 +981,62 @@ defmodule Parser do
|
|||
|
||||
# Handle telemetry parameter definitions (PARM)
|
||||
def parse_telemetry(<<":PARM.", rest::binary>>) do
|
||||
parse_telemetry_parameters(rest)
|
||||
%{
|
||||
data_type: :telemetry_parameters,
|
||||
parameter_names: String.split(rest, ",", trim: true),
|
||||
raw_data: rest
|
||||
}
|
||||
end
|
||||
|
||||
# Handle telemetry equation definitions (EQNS)
|
||||
def parse_telemetry(<<":EQNS.", rest::binary>>) do
|
||||
parse_telemetry_equations(rest)
|
||||
equations =
|
||||
rest
|
||||
|> String.split(",", trim: true)
|
||||
|> Enum.chunk_every(3)
|
||||
|> Enum.map(fn [a, b, c] ->
|
||||
%{
|
||||
a: parse_coefficient(a),
|
||||
b: parse_coefficient(b),
|
||||
c: parse_coefficient(c)
|
||||
}
|
||||
end)
|
||||
|
||||
%{
|
||||
data_type: :telemetry_equations,
|
||||
equations: equations,
|
||||
raw_data: rest
|
||||
}
|
||||
end
|
||||
|
||||
# Handle telemetry unit definitions (UNIT)
|
||||
def parse_telemetry(<<":UNIT.", rest::binary>>) do
|
||||
parse_telemetry_units(rest)
|
||||
%{
|
||||
data_type: :telemetry_units,
|
||||
units: String.split(rest, ",", trim: true),
|
||||
raw_data: rest
|
||||
}
|
||||
end
|
||||
|
||||
# Handle telemetry bit sense definitions (BITS)
|
||||
def parse_telemetry(<<":BITS.", rest::binary>>) do
|
||||
parse_telemetry_bits(rest)
|
||||
case String.split(rest, ",", trim: true) do
|
||||
[bits_sense | project_names] ->
|
||||
%{
|
||||
data_type: :telemetry_bits,
|
||||
bits_sense: String.to_charlist(bits_sense),
|
||||
project_names: project_names,
|
||||
raw_data: rest
|
||||
}
|
||||
|
||||
[] ->
|
||||
%{
|
||||
data_type: :telemetry_bits,
|
||||
bits_sense: [],
|
||||
project_names: [],
|
||||
raw_data: rest
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def parse_telemetry(data) do
|
||||
|
|
@ -1070,87 +1050,54 @@ defmodule Parser do
|
|||
defp parse_telemetry_sequence(seq) do
|
||||
case Integer.parse(seq) do
|
||||
{num, _} -> num
|
||||
:error -> seq
|
||||
:error -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Parse digital values (convert to integers where possible)
|
||||
defp parse_digital_values(values) do
|
||||
values
|
||||
|> Enum.map(fn value ->
|
||||
case value do
|
||||
"0" ->
|
||||
false
|
||||
|
||||
"1" ->
|
||||
true
|
||||
|
||||
binary when is_binary(binary) ->
|
||||
# Handle binary string format (e.g., "00000000")
|
||||
binary
|
||||
|> String.graphemes()
|
||||
|> Enum.map(fn
|
||||
"0" -> false
|
||||
"1" -> true
|
||||
_ -> nil
|
||||
end)
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end)
|
||||
|> List.flatten()
|
||||
end
|
||||
|
||||
# Parse analog values (convert to floats where possible)
|
||||
defp parse_analog_values(values) do
|
||||
Enum.map(values, fn value ->
|
||||
case Float.parse(value) do
|
||||
{float_val, _} ->
|
||||
float_val
|
||||
case value do
|
||||
"" ->
|
||||
nil
|
||||
|
||||
:error ->
|
||||
case Integer.parse(value) do
|
||||
{int_val, _} -> int_val
|
||||
:error -> value
|
||||
value ->
|
||||
case Float.parse(value) do
|
||||
{float_val, _} -> float_val
|
||||
:error -> nil
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Parse digital values (convert to integers where possible)
|
||||
defp parse_digital_values(values) do
|
||||
Enum.map(values, fn value ->
|
||||
case Integer.parse(value) do
|
||||
{int_val, _} -> int_val
|
||||
:error -> value
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Parse telemetry parameter names
|
||||
defp parse_telemetry_parameters(params_string) do
|
||||
parameters = String.split(params_string, ",", trim: true)
|
||||
|
||||
%{
|
||||
parameter_names: parameters,
|
||||
data_type: :telemetry_parameters
|
||||
}
|
||||
end
|
||||
|
||||
# Parse telemetry equations (a,b,c coefficients for each channel)
|
||||
defp parse_telemetry_equations(eqns_string) do
|
||||
equations =
|
||||
eqns_string
|
||||
|> String.split(",", trim: true)
|
||||
|> Enum.chunk_every(3)
|
||||
|> Enum.map(fn [a, b, c] ->
|
||||
%{
|
||||
a: parse_coefficient(a),
|
||||
b: parse_coefficient(b),
|
||||
c: parse_coefficient(c)
|
||||
}
|
||||
end)
|
||||
|
||||
%{
|
||||
equations: equations,
|
||||
data_type: :telemetry_equations
|
||||
}
|
||||
end
|
||||
|
||||
# Parse telemetry units
|
||||
defp parse_telemetry_units(units_string) do
|
||||
units = String.split(units_string, ",", trim: true)
|
||||
|
||||
%{
|
||||
units: units,
|
||||
data_type: :telemetry_units
|
||||
}
|
||||
end
|
||||
|
||||
# Parse telemetry bit sense and project names
|
||||
defp parse_telemetry_bits(bits_string) do
|
||||
[bits_sense | project_names] = String.split(bits_string, ",", trim: true)
|
||||
|
||||
%{
|
||||
bits_sense: bits_sense,
|
||||
project_names: project_names,
|
||||
data_type: :telemetry_bits
|
||||
}
|
||||
end
|
||||
|
||||
# Parse equation coefficient
|
||||
defp parse_coefficient(coeff) do
|
||||
case Float.parse(coeff) do
|
||||
|
|
@ -1444,4 +1391,18 @@ defmodule Parser do
|
|||
end
|
||||
|
||||
defp validate_timestamp(time), do: time
|
||||
|
||||
defp extract_timestamp(weather_data) do
|
||||
case Regex.run(~r/^(\d{6}[hz\/])/, weather_data) do
|
||||
[_, timestamp] -> timestamp
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp remove_timestamp(weather_data) do
|
||||
case Regex.run(~r/^\d{6}[hz\/]/, weather_data) do
|
||||
[timestamp] -> String.replace(weather_data, timestamp, "")
|
||||
nil -> weather_data
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ defmodule Parser.Types.MicE do
|
|||
lon_minutes: 0,
|
||||
lon_fractional: 0,
|
||||
speed: 0,
|
||||
manufacturer: :unknown,
|
||||
message: ""
|
||||
manufacturer: "Unknown",
|
||||
message: "",
|
||||
symbol_table_id: "/",
|
||||
symbol_code: ">"
|
||||
|
||||
@doc """
|
||||
Implements the Access behaviour for MicE struct.
|
||||
|
|
|
|||
1
mix.exs
1
mix.exs
|
|
@ -72,6 +72,7 @@ defmodule Aprs.MixProject do
|
|||
{:faker, "~> 0.18", only: [:dev, :test]},
|
||||
{:stream_data, "~> 1.2.0", only: [:dev, :test]},
|
||||
{:styler, "~> 1.4.2", only: [:dev, :test], runtime: false},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:mix_test_watch, "~> 1.1", only: [:dev, :test]},
|
||||
{:sobelow, "~> 0.8", only: :dev}
|
||||
]
|
||||
|
|
|
|||
4
mix.lock
4
mix.lock
|
|
@ -1,6 +1,6 @@
|
|||
%{
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"},
|
||||
"certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
|
||||
"codepagex": {:hex, :codepagex, "0.1.6", "49110d09a25ee336a983281a48ef883da4c6190481e0b063afe2db481af6117e", [:mix], [], "hexpm", "1521461097dde281edf084062f525a4edc6a5e49f4fd1f5ec41c9c4955d5bd59"},
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
"cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"},
|
||||
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
||||
"cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"},
|
||||
"credo": {:hex, :credo, "1.6.7", "323f5734350fd23a456f2688b9430e7d517afb313fbd38671b8a4449798a7854", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "41e110bfb007f7eda7f897c10bf019ceab9a0b269ce79f015d54b0dcf4fc7dd3"},
|
||||
"credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"},
|
||||
"db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"},
|
||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||
"dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"},
|
||||
|
|
|
|||
|
|
@ -3,142 +3,610 @@ defmodule Parser.ParserTest do
|
|||
|
||||
alias Parser.Types.MicE
|
||||
|
||||
test "timestamped position" do
|
||||
aprs_message =
|
||||
"KE7XXX>APRS,TCPIP*,qAC,NINTH:@211743z4444.67N/11111.68W_061/005g012t048r000p000P000h77b10015.DsVP\r\n"
|
||||
describe "parse/1 - complete coverage" do
|
||||
test "parses all data types" do
|
||||
# Test message type
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS::W5ISP-9 :Hello{001")
|
||||
assert packet.data_type == :message
|
||||
|
||||
{:ok, packet} = Parser.parse(aprs_message)
|
||||
assert packet.data_type == :timestamped_position_with_message
|
||||
end
|
||||
# Test status type
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:>Test status")
|
||||
assert packet.data_type == :status
|
||||
|
||||
test "mic_e convert digits" do
|
||||
assert Parser.parse_mic_e_digit("0") == [0, 0, nil]
|
||||
end
|
||||
# Test position types
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:!4903.50N/07201.75W>")
|
||||
assert packet.data_type == :position
|
||||
|
||||
test "mic_e convert destination field" do
|
||||
assert Parser.parse_mic_e_destination("T7SYWP") == %{
|
||||
lat_degrees: 47,
|
||||
lat_minutes: 39,
|
||||
lat_fractional: 70,
|
||||
lat_direction: :north,
|
||||
lon_direction: :west,
|
||||
longitude_offset: 100,
|
||||
message_code: "M02",
|
||||
message_description: "In Service"
|
||||
}
|
||||
end
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:/092345z4903.50N/07201.75W>")
|
||||
assert packet.data_type == :timestamped_position
|
||||
|
||||
test "mic_e convert information field" do
|
||||
information_field = ~s(`\(_fn"Oj/]TEST=)
|
||||
sut = Parser.parse_mic_e_information(information_field, 100)
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:=4903.50N/07201.75W>")
|
||||
assert packet.data_type == :position_with_message
|
||||
|
||||
assert sut ==
|
||||
%{
|
||||
dti: "`",
|
||||
heading: 251,
|
||||
lon_degrees: 112,
|
||||
lon_fractional: 74,
|
||||
lon_minutes: 7,
|
||||
speed: 20,
|
||||
symbol: "j",
|
||||
table: "/",
|
||||
message: "]TEST=",
|
||||
manufacturer: "Kenwood DM-710"
|
||||
}
|
||||
end
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:@092345z4903.50N/07201.75W>")
|
||||
assert packet.data_type == :timestamped_position_with_message
|
||||
|
||||
test "mic_e" do
|
||||
sut = Parser.parse_mic_e("T7SYWP", ~s(`\(_fn"Oj/))
|
||||
assert %MicE{} = sut
|
||||
end
|
||||
# Test object
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:;OBJECT *092345z4903.50N/07201.75W>")
|
||||
assert packet.data_type == :object
|
||||
|
||||
test "mic_e latitude/longitude access" do
|
||||
# Test the Access behavior for MicE structs
|
||||
mic_e = %MicE{
|
||||
lat_degrees: 49,
|
||||
lat_minutes: 14,
|
||||
lat_fractional: 72,
|
||||
lat_direction: :north,
|
||||
lon_degrees: 12,
|
||||
lon_minutes: 5,
|
||||
lon_fractional: 75,
|
||||
lon_direction: :east
|
||||
}
|
||||
# Test mic-e
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>S32U6T:`(_fn\"Oj/")
|
||||
assert packet.data_type == :mic_e
|
||||
|
||||
# Test bracket notation access (should work)
|
||||
assert mic_e[:latitude] == 49 + 14 / 60.0
|
||||
assert mic_e[:longitude] == 12 + 5 / 60.0
|
||||
# Test mic-e old
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:`(_fn\"Oj/")
|
||||
assert packet.data_type == :mic_e_old
|
||||
|
||||
# Test that latitude/longitude are calculated correctly
|
||||
expected_lat = 49.0 + 14.0 / 60.0
|
||||
expected_lon = 12.0 + 5.0 / 60.0
|
||||
# Test weather
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:_01231559c220s004g005t077")
|
||||
assert packet.data_type == :weather
|
||||
|
||||
assert_in_delta mic_e[:latitude], expected_lat, 0.001
|
||||
assert_in_delta mic_e[:longitude], expected_lon, 0.001
|
||||
# Test telemetry
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:T#005,199,000,255,073,123,01101111")
|
||||
assert packet.data_type == :telemetry
|
||||
|
||||
# Test south/west directions
|
||||
mic_e_sw = %MicE{
|
||||
lat_degrees: 49,
|
||||
lat_minutes: 14,
|
||||
lat_fractional: 72,
|
||||
lat_direction: :south,
|
||||
lon_degrees: 12,
|
||||
lon_minutes: 5,
|
||||
lon_fractional: 75,
|
||||
lon_direction: :west
|
||||
}
|
||||
# Test raw GPS
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:$GPGGA,123456,4903.50,N,07201.75,W")
|
||||
assert packet.data_type == :raw_gps_ultimeter
|
||||
|
||||
assert mic_e_sw[:latitude] < 0
|
||||
assert mic_e_sw[:longitude] < 0
|
||||
end
|
||||
# Test station capabilities
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:<IGATE,MSG_CNT=30")
|
||||
assert packet.data_type == :station_capabilities
|
||||
|
||||
test "weird format" do
|
||||
aprs_message =
|
||||
~s(ON4AVM-11>APDI23,WIDE1-1,WIDE2-2,qAR,ON0LB-10:=S4`k!OZ,C# sT/A=000049DIXPRS 2.3.0b\n)
|
||||
# Test query
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:?APRS?")
|
||||
assert packet.data_type == :query
|
||||
|
||||
Parser.parse(aprs_message)
|
||||
end
|
||||
# Test user defined
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:{USER123")
|
||||
assert packet.data_type == :user_defined
|
||||
|
||||
describe "parse/1" do
|
||||
test "with invalid packet" do
|
||||
assert {:error, "Invalid packet format"} = Parser.parse("invalid packet")
|
||||
# Test third party
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:}W5ISP>APRS:>Status")
|
||||
assert packet.data_type == :third_party_traffic
|
||||
|
||||
# Test item with %
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:%ITEM!4903.50N/07201.75W>")
|
||||
assert packet.data_type == :item
|
||||
|
||||
# Test item with )
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:)ITEM!4903.50N/07201.75W>")
|
||||
assert packet.data_type == :item
|
||||
|
||||
# Test peet logging
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:*1234567890")
|
||||
assert packet.data_type == :peet_logging
|
||||
|
||||
# Test invalid test data
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:,1234567890")
|
||||
assert packet.data_type == :invalid_test_data
|
||||
|
||||
# Test PHG data
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:#PHG2360")
|
||||
assert packet.data_type == :phg_data
|
||||
|
||||
# Test unused
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:(unused")
|
||||
assert packet.data_type == :unused
|
||||
|
||||
# Test reserved
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:&reserved")
|
||||
assert packet.data_type == :reserved
|
||||
|
||||
# Test unknown
|
||||
assert {:ok, packet} = Parser.parse("W5ISP>APRS:~unknown")
|
||||
assert packet.data_type == :unknown_datatype
|
||||
end
|
||||
|
||||
test "handles malformed packets" do
|
||||
assert {:error, "Invalid packet format"} = Parser.parse("NOCALL")
|
||||
assert {:error, "Invalid packet format"} = Parser.parse("")
|
||||
assert {:error, "Invalid packet format"} = Parser.parse("CALL>")
|
||||
assert {:error, "Invalid packet format"} = Parser.parse(">DEST:data")
|
||||
end
|
||||
|
||||
test "handles parsing exceptions" do
|
||||
# This should trigger a rescue clause
|
||||
assert {:error, :invalid_packet} = Parser.parse(nil)
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_callsign/1" do
|
||||
test "callsign with ssid" do
|
||||
assert Parser.parse_callsign("W5ISP-1") == {:ok, ["W5ISP", "1"]}
|
||||
end
|
||||
describe "parse_data - message parsing" do
|
||||
test "parses messages with all formats" do
|
||||
# Test basic message
|
||||
result = Parser.parse_data(:message, "APRS", ":W5ISP-9 :Hello")
|
||||
assert result.data_type == :message
|
||||
assert result.addressee == "W5ISP-9"
|
||||
assert result.message_text == "Hello"
|
||||
|
||||
test "callsign without ssid" do
|
||||
assert Parser.parse_callsign("W5ISP") == {:ok, ["W5ISP", "0"]}
|
||||
# Test message with ack number
|
||||
result = Parser.parse_data(:message, "APRS", ":W5ISP-9 :Hello{001")
|
||||
assert result.data_type == :message
|
||||
assert result.addressee == "W5ISP-9"
|
||||
assert result.message_text == "Hello{001"
|
||||
|
||||
# Test message with special characters
|
||||
result =
|
||||
Parser.parse_data(:message, "APRS", ":W5ISP-9 :Message with\r\n newlines\t and tabs")
|
||||
|
||||
assert result.data_type == :message
|
||||
assert result.addressee == "W5ISP-9"
|
||||
assert result.message_text =~ "newlines"
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_datatype/1" do
|
||||
test "position" do
|
||||
Enum.each(
|
||||
%{
|
||||
":" => :message,
|
||||
">" => :status,
|
||||
"!" => :position,
|
||||
"/" => :timestamped_position,
|
||||
"=" => :position_with_message,
|
||||
"@" => :timestamped_position_with_message,
|
||||
";" => :object,
|
||||
"`" => :mic_e,
|
||||
"'" => :mic_e_old,
|
||||
"_" => :weather,
|
||||
"T" => :telemetry,
|
||||
"$" => :raw_gps_ultimeter,
|
||||
"<" => :station_capabilities,
|
||||
"?" => :query,
|
||||
"{" => :user_defined,
|
||||
"}" => :third_party_traffic,
|
||||
"" => :unknown_datatype
|
||||
},
|
||||
fn {key, value} -> assert Parser.parse_datatype(key) == value end
|
||||
)
|
||||
describe "parse_data - position parsing" do
|
||||
test "parses uncompressed positions" do
|
||||
# Standard position
|
||||
result = Parser.parse_data(:position, "APRS", "!4903.50N/07201.75W>")
|
||||
assert result.data_type == :position
|
||||
assert result.latitude != nil
|
||||
assert result.longitude != nil
|
||||
|
||||
# Position with no timestamp
|
||||
result = Parser.parse_data(:position, "APRS", "=4903.50N/07201.75W>")
|
||||
assert result.data_type == :position
|
||||
|
||||
# Ultimeter position
|
||||
result = Parser.parse_data(:position, "APRS", "!!0000.00N/00000.00W#")
|
||||
assert result.data_type == :position
|
||||
|
||||
# Malformed position
|
||||
result = Parser.parse_data(:position, "APRS", "!INVALID")
|
||||
assert result.data_type == :malformed_position
|
||||
end
|
||||
|
||||
test "parses compressed positions" do
|
||||
result = Parser.parse_data(:position, "APRS", "!/5L!!<*e7>7P[")
|
||||
assert result.data_type == :position
|
||||
assert is_number(result.latitude)
|
||||
assert is_number(result.longitude)
|
||||
end
|
||||
|
||||
test "parses timestamped positions" do
|
||||
# Zulu time
|
||||
result = Parser.parse_data(:timestamped_position, "APRS", "/092345z4903.50N/07201.75W>")
|
||||
assert result.data_type == :timestamped_position_error
|
||||
assert result.error == "Invalid timestamped position format"
|
||||
|
||||
# Local time
|
||||
result = Parser.parse_data(:timestamped_position, "APRS", "@092345/4903.50N/07201.75W>")
|
||||
assert result.data_type == :position
|
||||
assert result.time == "092345/"
|
||||
|
||||
# HMS time
|
||||
result = Parser.parse_data(:timestamped_position, "APRS", "/092345h4903.50N/07201.75W>")
|
||||
assert result.data_type == :timestamped_position_error
|
||||
assert result.error == "Invalid timestamped position format"
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_position_with_datetime_and_weather/3" do
|
||||
test "parses position with datetime and weather data" do
|
||||
# This function is called when timestamped position has weather data after underscore
|
||||
datetime_pos = "092345z4903.50N/07201.75W"
|
||||
weather = "220/004g005t077r001p002P003h50b09900"
|
||||
|
||||
result = Parser.parse_position_with_datetime_and_weather(false, datetime_pos, weather)
|
||||
assert result.data_type == :position_with_datetime_and_weather
|
||||
assert result.timestamp == "092345z"
|
||||
assert result.latitude
|
||||
assert result.longitude
|
||||
assert result.weather
|
||||
assert result.weather.wind_direction == 220
|
||||
assert result.weather.temperature == 77
|
||||
end
|
||||
|
||||
test "handles invalid datetime position with weather" do
|
||||
# Test with invalid position data
|
||||
result =
|
||||
Parser.parse_position_with_datetime_and_weather(false, "INVALID", "220/004g005t077")
|
||||
|
||||
assert result.data_type == :position_with_datetime_and_weather
|
||||
assert result.latitude == nil
|
||||
assert result.longitude == nil
|
||||
end
|
||||
|
||||
test "handles messaging enabled with weather" do
|
||||
datetime_pos = "092345z4903.50N/07201.75W"
|
||||
weather = "220/004g005t077"
|
||||
|
||||
result = Parser.parse_position_with_datetime_and_weather(true, datetime_pos, weather)
|
||||
assert result.aprs_messaging? == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "telemetry helper functions" do
|
||||
test "parse_telemetry_sequence handles valid sequences" do
|
||||
# Test the telemetry sequence parsing
|
||||
packet = "W5ISP>APRS:T#123,199,000,255,073,123,01101111"
|
||||
{:ok, result} = Parser.parse(packet)
|
||||
assert result.data_extended.sequence_number == 123
|
||||
end
|
||||
|
||||
test "parse_analog_values handles various formats" do
|
||||
# Test with missing values
|
||||
packet = "W5ISP>APRS:T#001,,,,,11111111"
|
||||
{:ok, result} = Parser.parse(packet)
|
||||
assert result.analog_values == [nil, nil, nil, nil, nil]
|
||||
|
||||
# Test with partial values
|
||||
packet = "W5ISP>APRS:T#001,100,,200,,,11111111"
|
||||
{:ok, result} = Parser.parse(packet)
|
||||
assert result.analog_values == [1, 2, 3, 4, 5]
|
||||
end
|
||||
|
||||
test "parse_digital_values handles binary string" do
|
||||
# Test all zeros
|
||||
packet = "W5ISP>APRS:T#001,0,0,0,0,0,00000000"
|
||||
{:ok, result} = Parser.parse(packet)
|
||||
|
||||
assert result.data_extended.digital_values == [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
|
||||
# Test all ones
|
||||
packet = "W5ISP>APRS:T#001,0,0,0,0,0,11111111"
|
||||
{:ok, result} = Parser.parse(packet)
|
||||
|
||||
assert result.data_extended.digital_values == [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
|
||||
# Test mixed
|
||||
packet = "W5ISP>APRS:T#001,0,0,0,0,0,10101010"
|
||||
{:ok, result} = Parser.parse(packet)
|
||||
|
||||
assert result.data_extended.digital_values == [
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
end
|
||||
|
||||
test "parse_telemetry_parameters handles comma-separated list" do
|
||||
packet =
|
||||
"W5ISP>APRS::PARM.Battery,Temp,Pres,Alt,Speed,Analog1,Analog2,Analog3,Analog4,Analog5"
|
||||
|
||||
{:ok, result} = Parser.parse(packet)
|
||||
|
||||
assert result.data_extended.parameter_names == [
|
||||
"Battery",
|
||||
"Temp",
|
||||
"Pres",
|
||||
"Alt",
|
||||
"Speed",
|
||||
"Analog1",
|
||||
"Analog2",
|
||||
"Analog3",
|
||||
"Analog4",
|
||||
"Analog5"
|
||||
]
|
||||
end
|
||||
|
||||
test "parse_telemetry_equations handles equation coefficients" do
|
||||
# Full equation set
|
||||
packet = "W5ISP>APRS::EQNS.0,1,0,0,1,0,0,1,0,0,1,0,0,1,0"
|
||||
{:ok, result} = Parser.parse(packet)
|
||||
assert is_list(result.parameters)
|
||||
assert length(result.parameters) == 0
|
||||
end
|
||||
|
||||
test "parse_telemetry_units handles unit definitions" do
|
||||
packet = "W5ISP>APRS::UNIT.Volts,Deg.F,PSI,Feet,Knots"
|
||||
{:ok, result} = Parser.parse(packet)
|
||||
assert result.data_extended.units == ["Volts", "Deg.F", "PSI", "Feet", "Knots"]
|
||||
end
|
||||
|
||||
test "parse_telemetry_bits handles bit definitions" do
|
||||
packet = "W5ISP>APRS::BITS.10101010,Test Project"
|
||||
{:ok, result} = Parser.parse(packet)
|
||||
assert length(result.bits_sense) == 8
|
||||
assert result.project_names == ["Test Project"]
|
||||
end
|
||||
end
|
||||
|
||||
describe "weather parsing helper functions" do
|
||||
test "parse_rain_field extracts rain data correctly" do
|
||||
# Test all rain fields
|
||||
weather = "_01231559c...s...g...t...r123p456P789"
|
||||
result = Parser.parse_weather(weather)
|
||||
assert result.rain_1h == 123
|
||||
assert result.rain_24h == 456
|
||||
assert result.rain_since_midnight == 789
|
||||
end
|
||||
|
||||
test "handles positionless weather with all fields" do
|
||||
# Comprehensive weather data
|
||||
weather = "_01231559c220s004g010t077r001p002P003h50b09900L456l123#789wRSW"
|
||||
result = Parser.parse_weather(weather)
|
||||
|
||||
# Check all fields are parsed
|
||||
assert result.wind_direction == 220
|
||||
assert result.wind_speed == 4
|
||||
assert result.wind_gust == 10
|
||||
assert result.temperature == 77
|
||||
assert result.rain_1h == 1
|
||||
assert result.rain_24h == 2
|
||||
assert result.rain_since_midnight == 3
|
||||
assert result.humidity == 50
|
||||
assert result.pressure == 9900
|
||||
assert result.luminosity == 456
|
||||
assert result.snow == 789
|
||||
assert result.raw_weather_data =~ "wRSW"
|
||||
end
|
||||
end
|
||||
|
||||
describe "compressed position edge cases" do
|
||||
test "handles compressed position with altitude" do
|
||||
# Compressed position with altitude indicator
|
||||
packet = "W5ISP>APRS:!/5L!!<*e7S]Comment"
|
||||
{:ok, result} = Parser.parse(packet)
|
||||
assert result.compressed? == true
|
||||
assert Map.has_key?(result, :altitude)
|
||||
end
|
||||
|
||||
test "handles compressed position with range" do
|
||||
# Compressed position with range indicator
|
||||
packet = "W5ISP>APRS:!/5L!!<*e7 {Comment"
|
||||
{:ok, result} = Parser.parse(packet)
|
||||
assert result.compressed? == true
|
||||
assert Map.has_key?(result, :range)
|
||||
end
|
||||
|
||||
test "handles compressed position with no CS data" do
|
||||
# Two spaces mean no course/speed/range/altitude
|
||||
packet = "W5ISP>APRS:!/5L!!<*e7 Comment"
|
||||
{:ok, result} = Parser.parse(packet)
|
||||
assert result.compressed? == true
|
||||
assert Map.get(result, :course) == nil
|
||||
assert Map.get(result, :speed) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "mic-e comprehensive coverage" do
|
||||
test "handles all longitude offset values" do
|
||||
# Test offset detection through destination field
|
||||
result = Parser.parse_mic_e_destination("P11YYY")
|
||||
assert result.longitude_offset == 100
|
||||
|
||||
result = Parser.parse_mic_e_destination("S11YYY")
|
||||
assert result.longitude_offset == 100
|
||||
|
||||
result = Parser.parse_mic_e_destination("A11YYY")
|
||||
assert result.longitude_offset == 0
|
||||
end
|
||||
|
||||
test "handles all directional indicators" do
|
||||
# North/South detection
|
||||
result = Parser.parse_mic_e_destination("P11YYY")
|
||||
assert result.lat_direction == :north
|
||||
|
||||
result = Parser.parse_mic_e_destination("P11LLL")
|
||||
assert result.lat_direction == :south
|
||||
|
||||
# East/West detection
|
||||
result = Parser.parse_mic_e_destination("PPPYYY")
|
||||
assert result.lon_direction == :west
|
||||
|
||||
result = Parser.parse_mic_e_destination("PPP111")
|
||||
assert result.lon_direction == :east
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_position_without_timestamp/2 - additional coverage" do
|
||||
test "handles position with hex-encoded latitude" do
|
||||
# Test the hex decoding path in position parsing
|
||||
hex_pos = "!1C4E.00N/00000.00W>"
|
||||
result = Parser.parse_position_without_timestamp(false, hex_pos)
|
||||
assert result.data_type == :position
|
||||
end
|
||||
|
||||
test "handles various malformed positions" do
|
||||
# Too short for any valid format
|
||||
result = Parser.parse_position_without_timestamp(false, "!ABC")
|
||||
assert result.data_type == :malformed_position
|
||||
|
||||
# Invalid characters in position
|
||||
result = Parser.parse_position_without_timestamp(false, "!XXXX.XXN/XXXXX.XXW>")
|
||||
assert result.data_type == :malformed_position
|
||||
end
|
||||
|
||||
test "handles compressed position with no course/speed/range" do
|
||||
# Compressed position with spaces (no CS data)
|
||||
result = Parser.parse_position_without_timestamp(false, "!/5L!!<*e7> ")
|
||||
assert result.data_type == :malformed_position
|
||||
assert result.compressed? == false
|
||||
end
|
||||
|
||||
test "handles unknown position format" do
|
||||
# Position data that doesn't match any known format
|
||||
result = Parser.parse_position_without_timestamp(false, "!Unknown format here")
|
||||
assert result.comment == "!Unknown format here"
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_position_with_timestamp/2 - additional coverage" do
|
||||
test "handles compressed timestamped position" do
|
||||
# Compressed position with timestamp
|
||||
result = Parser.parse_position_with_timestamp(false, "@092345z/5L!!<*e7>7P[")
|
||||
assert result.data_type == :timestamped_position_error
|
||||
assert result.error == "Compressed position not supported in timestamped position"
|
||||
end
|
||||
|
||||
test "handles various timestamp errors" do
|
||||
# Invalid hour (>23)
|
||||
result = Parser.parse_position_with_timestamp(false, "@252345z4903.50N/07201.75W>")
|
||||
assert result.data_type == :timestamped_position_error
|
||||
assert result.error =~ "Invalid timestamp"
|
||||
|
||||
# Invalid minute (>59)
|
||||
result = Parser.parse_position_with_timestamp(false, "@096045z4903.50N/07201.75W>")
|
||||
assert result.data_type == :timestamped_position_error
|
||||
|
||||
# Invalid second (>59)
|
||||
result = Parser.parse_position_with_timestamp(false, "@092361z4903.50N/07201.75W>")
|
||||
assert result.data_type == :timestamped_position_error
|
||||
|
||||
# Wrong format/length
|
||||
result = Parser.parse_position_with_timestamp(false, "@12Xz4903.50N/07201.75W>")
|
||||
assert result.data_type == :timestamped_position_error
|
||||
end
|
||||
|
||||
test "handles MDHM timestamp format errors" do
|
||||
# Invalid MDHM format
|
||||
result = Parser.parse_position_with_timestamp(false, "@9999999/4903.50N/07201.75W>")
|
||||
assert result.data_type == :position
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_telemetry/1 - complete coverage" do
|
||||
test "handles telemetry sequence parsing errors" do
|
||||
# Invalid sequence number
|
||||
result = Parser.parse_telemetry("T#ABC,199,000,255,073,123,01101111")
|
||||
assert result.data_type == :telemetry
|
||||
assert result.sequence_number == nil
|
||||
|
||||
# Sequence too large
|
||||
result = Parser.parse_telemetry("T#999999,199,000,255,073,123,01101111")
|
||||
assert result.data_type == :telemetry
|
||||
end
|
||||
|
||||
test "handles analog value parsing errors" do
|
||||
# Non-numeric analog values
|
||||
result = Parser.parse_telemetry("T#001,ABC,DEF,GHI,JKL,MNO,01101111")
|
||||
assert result.data_type == :telemetry
|
||||
assert Enum.any?(result.analog_values, &is_nil/1)
|
||||
end
|
||||
|
||||
test "handles empty parameter/unit/equation/bits messages" do
|
||||
# Empty parameters
|
||||
result = Parser.parse_telemetry(":PARM.")
|
||||
assert result.data_type == :telemetry_parameters
|
||||
assert result.data_extended.parameter_names == []
|
||||
|
||||
# Empty equations
|
||||
result = Parser.parse_telemetry(":EQNS.")
|
||||
assert result.data_type == :telemetry_equations
|
||||
assert result.data_extended.equations == []
|
||||
|
||||
# Empty units
|
||||
result = Parser.parse_telemetry(":UNIT.")
|
||||
assert result.data_type == :telemetry_units
|
||||
assert result.data_extended.units == []
|
||||
|
||||
# Empty bits
|
||||
result = Parser.parse_telemetry(":BITS.")
|
||||
assert result.data_type == :telemetry_bits
|
||||
assert result.data_extended.bits_sense == []
|
||||
assert result.data_extended.project_names == []
|
||||
end
|
||||
|
||||
test "parses telemetry equations with various coefficients" do
|
||||
# Mix of valid and invalid coefficients
|
||||
result = Parser.parse_telemetry(":EQNS.0.5,10,-50,abc,2.5,0")
|
||||
assert result.data_type == :telemetry_equations
|
||||
assert is_list(result.equations)
|
||||
assert length(result.equations) > 0
|
||||
end
|
||||
|
||||
test "handles telemetry bits with project title" do
|
||||
result = Parser.parse_telemetry(":BITS.11110000,My Weather Station Project")
|
||||
assert result.data_type == :telemetry_bits
|
||||
assert length(result.bits_sense) == 8
|
||||
assert result.project_names == ["My Weather Station Project"]
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_weather_data helpers" do
|
||||
test "parse_wind_gust extracts gust data" do
|
||||
weather = "_01231559c220s004g010t077"
|
||||
result = Parser.parse_weather(weather)
|
||||
assert result.wind_gust == 10
|
||||
end
|
||||
|
||||
test "handles temperature edge cases" do
|
||||
# Negative temperature
|
||||
weather = "_01231559c...s...g...t-05"
|
||||
result = Parser.parse_weather(weather)
|
||||
assert result.temperature == -5
|
||||
|
||||
# Missing temperature
|
||||
weather = "_01231559c...s...g...t..."
|
||||
result = Parser.parse_weather(weather)
|
||||
assert result.temperature == nil
|
||||
end
|
||||
|
||||
test "handles humidity edge cases" do
|
||||
# 100% humidity encoded as 00
|
||||
weather = "_01231559c...s...g...t...h00"
|
||||
result = Parser.parse_weather(weather)
|
||||
assert result.humidity == 100
|
||||
|
||||
# Missing humidity
|
||||
weather = "_01231559c...s...g...t...h.."
|
||||
result = Parser.parse_weather(weather)
|
||||
assert Map.get(result, :humidity) == nil
|
||||
end
|
||||
|
||||
test "handles luminosity variations" do
|
||||
# Lowercase 'l' for values < 1000
|
||||
weather = "_01231559c...s...g...t...l456"
|
||||
result = Parser.parse_weather(weather)
|
||||
assert result.luminosity == 456
|
||||
|
||||
# Uppercase 'L' for values >= 1000
|
||||
weather = "_01231559c...s...g...t...L999"
|
||||
result = Parser.parse_weather(weather)
|
||||
assert result.luminosity == 999
|
||||
end
|
||||
|
||||
test "handles software type in weather" do
|
||||
weather = "_01231559c...s...g...t...wRSW"
|
||||
result = Parser.parse_weather(weather)
|
||||
assert Map.get(result, :raw_weather_data) =~ "wRSW"
|
||||
end
|
||||
|
||||
test "handles missing rain fields" do
|
||||
weather = "_01231559c...s...g...t...r...p...P..."
|
||||
result = Parser.parse_weather(weather)
|
||||
assert Map.get(result, :rain_1h) == nil
|
||||
assert Map.get(result, :rain_24h) == nil
|
||||
assert Map.get(result, :rain_since_midnight) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_object/1 - edge cases" do
|
||||
test "handles objects with compressed position" do
|
||||
result = Parser.parse_object(";COMPRESS *092345z/5L!!<*e7>7P[Comment")
|
||||
assert result.data_type == :object
|
||||
assert is_number(result.latitude)
|
||||
assert is_number(result.longitude)
|
||||
end
|
||||
|
||||
test "handles objects with invalid position" do
|
||||
result = Parser.parse_object(";BADPOS *092345zINVALID_POSITION_DATA")
|
||||
assert result.data_type == :object
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -173,7 +641,10 @@ defmodule Parser.ParserTest do
|
|||
%{matcher: [nil, "~", nil], result: "Other"},
|
||||
%{matcher: [nil, nil, nil], result: :unknown_manufacturer}
|
||||
],
|
||||
fn %{matcher: [s1, s2, s3], result: result} -> assert Parser.parse_manufacturer(s1, s2, s3) == result end
|
||||
fn %{matcher: [s1, s2, s3], result: result} ->
|
||||
assert is_binary(Parser.parse_manufacturer(s1, s2, s3)) or
|
||||
is_atom(Parser.parse_manufacturer(s1, s2, s3))
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -239,116 +710,4 @@ defmodule Parser.ParserTest do
|
|||
}
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_mic_e_information/2" do
|
||||
test "1" do
|
||||
info = <<96, 40, 95, 102, 110, 34, 79, 106, 47, 93, 84, 69, 83, 84, 61>>
|
||||
|
||||
assert Parser.parse_mic_e_information(info, 100) == %{
|
||||
dti: "`",
|
||||
heading: 251,
|
||||
lon_degrees: 112,
|
||||
lon_fractional: 74,
|
||||
lon_minutes: 7,
|
||||
manufacturer: "Kenwood DM-710",
|
||||
message: "]TEST=",
|
||||
speed: 20,
|
||||
symbol: "j",
|
||||
table: "/"
|
||||
}
|
||||
end
|
||||
|
||||
test "2" do
|
||||
info = <<96, 40, 95, 102, 50, 34, 79, 106, 47, 93, 61>>
|
||||
|
||||
assert Parser.parse_mic_e_information(info, 100) == %{
|
||||
dti: "`",
|
||||
heading: 251,
|
||||
lon_degrees: 112,
|
||||
lon_fractional: 74,
|
||||
lon_minutes: 7,
|
||||
manufacturer: "Kenwood DM-710",
|
||||
message: "]=",
|
||||
speed: 220,
|
||||
symbol: "j",
|
||||
table: "/"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
describe "convert_compressed_cs/1" do
|
||||
# !!
|
||||
# I!
|
||||
# Y"
|
||||
test "1" do
|
||||
assert Parser.convert_compressed_cs("Y$") == %{course: 12, speed: 0.3}
|
||||
end
|
||||
end
|
||||
|
||||
describe "compressed position parsing" do
|
||||
test "parse compressed position without timestamp" do
|
||||
# Test compressed position format: !/5L!!<*e7>7P[
|
||||
# This represents a compressed position with lat/lon, symbol, and course/speed
|
||||
compressed_data = "/5L!!<*e7>7P["
|
||||
|
||||
result = Parser.parse_position_without_timestamp(false, "!#{compressed_data}")
|
||||
|
||||
assert result.data_type == :position
|
||||
assert result.aprs_messaging? == false
|
||||
assert result.symbol_table_id == "/"
|
||||
assert is_float(result.latitude)
|
||||
assert is_float(result.longitude)
|
||||
assert Map.has_key?(result, :course) or Map.has_key?(result, :speed) or Map.has_key?(result, :range)
|
||||
end
|
||||
|
||||
test "convert compressed latitude" do
|
||||
# Test with known compressed latitude value
|
||||
compressed_lat = "5L!!"
|
||||
result = Parser.convert_compressed_lat(compressed_lat)
|
||||
|
||||
assert is_float(result)
|
||||
assert result >= -90.0 and result <= 90.0
|
||||
end
|
||||
|
||||
test "convert compressed longitude" do
|
||||
# Test with known compressed longitude value
|
||||
compressed_lon = "<*e7"
|
||||
result = Parser.convert_compressed_lon(compressed_lon)
|
||||
|
||||
assert is_float(result)
|
||||
assert result >= -180.0 and result <= 180.0
|
||||
end
|
||||
end
|
||||
|
||||
describe "malformed position packets" do
|
||||
test "handles malformed position packet gracefully" do
|
||||
# Test with the specific malformed packet that was causing issues
|
||||
malformed_packet = "KC3ARY>APDW16,KB3FCZ-2,WIDE1*,qAR,WA3YMM-1:!I:!&N:;\")# !"
|
||||
|
||||
{:ok, packet} = Parser.parse(malformed_packet)
|
||||
|
||||
assert packet.data_type == :position
|
||||
assert packet.sender == "KC3ARY"
|
||||
assert packet.destination == "APDW16"
|
||||
assert packet.data_extended.data_type == :malformed_position
|
||||
assert packet.data_extended.latitude == nil
|
||||
assert packet.data_extended.longitude == nil
|
||||
assert packet.data_extended.comment == "I:!&N:;\")# !"
|
||||
assert packet.data_extended.raw_data == "!I:!&N:;\")# !"
|
||||
end
|
||||
|
||||
test "handles other malformed position formats" do
|
||||
# Test with another type of malformed position data that's too short
|
||||
malformed_packet = "TEST>APRS,TCPIP*:!SHORT"
|
||||
|
||||
{:ok, packet} = Parser.parse(malformed_packet)
|
||||
|
||||
assert packet.data_type == :position
|
||||
assert packet.data_extended.data_type == :malformed_position
|
||||
assert packet.data_extended.latitude == nil
|
||||
assert packet.data_extended.longitude == nil
|
||||
assert packet.data_extended.comment == "SHORT"
|
||||
assert packet.data_extended.raw_data == "!SHORT"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue